diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4d88e0f..8758b7a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "fantomas": { - "version": "5.2.1", + "version": "8.0.0-beta-002", "commands": [ "fantomas" - ] + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 1f7ae51..f511c15 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,8 +9,10 @@ fsharp_max_record_width=60 fsharp_max_array_or_list_width=120 fsharp_max_value_binding_width=120 fsharp_max_function_binding_width=120 -fsharp_max_dot_get_expression_width=120 -fsharp_multiline_block_brackets_on_same_column=true +fsharp_multiline_bracket_style=stroustrup +fsharp_newline_before_multiline_computation_expression=false fsharp_multi_line_lambda_closing_newline=true fsharp_blank_lines_around_nested_multiline_expressions=false -fsharp_experimental_stroustrup_style=true \ No newline at end of file + +[*.fsx] +fsharp_newline_before_multiline_computation_expression=true diff --git a/.github/workflows/Build and publish nuget package.yml b/.github/workflows/Build and publish nuget package.yml index 25fc4ed..1c5f539 100644 --- a/.github/workflows/Build and publish nuget package.yml +++ b/.github/workflows/Build and publish nuget package.yml @@ -14,12 +14,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Setup .NET Core - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x - name: Use build.fsx env: diff --git a/.github/workflows/Run tests for PR.yml b/.github/workflows/Run tests for PR.yml index fdbe8bf..4b3b8ed 100644 --- a/.github/workflows/Run tests for PR.yml +++ b/.github/workflows/Run tests for PR.yml @@ -10,12 +10,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Setup .NET Core - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x - name: Use build.fsx run: dotnet fsi ./build.fsx -- -p test diff --git a/Fun.Build.Cli/History.fs b/Fun.Build.Cli/History.fs index 898e7e8..5f66187 100644 --- a/Fun.Build.Cli/History.fs +++ b/Fun.Build.Cli/History.fs @@ -101,67 +101,66 @@ type History = ?withLastArgs: bool, ?promptForContinuation: bool, ?continuationItems: Collections.Generic.List - ) = - asyncResult { - let withLastArgs = defaultArg withLastArgs false - let scriptFile = Path.GetFileName history.Pipeline.Script - let scriptDir = Path.GetDirectoryName history.Pipeline.Script - let promptForContinuation = defaultArg promptForContinuation false - - let table = Table() - table.ShowHeaders <- false - table.AddColumns("", "") |> ignore - table.AddRow("Script", $"[green]{scriptFile}[/]") |> ignore - table.AddRow("Pipeline", $"[bold green]{history.Pipeline.Name}[/]") |> ignore - table.AddRow("WorkingDir", $"[green]{scriptDir}[/]") |> ignore - table.AddRow("Arguments", history.Args) |> ignore - - AnsiConsole.Write table - AnsiConsole.MarkupLine("New arguments (leave empty to use the one from history):") - - let args = - if withLastArgs then - history.Args - else - let text = TextPrompt("> ") - text.AllowEmpty <- true - let newArgs = AnsiConsole.Prompt text - if String.IsNullOrEmpty newArgs then history.Args else newArgs - + ) = asyncResult { + let withLastArgs = defaultArg withLastArgs false + let scriptFile = Path.GetFileName history.Pipeline.Script + let scriptDir = Path.GetDirectoryName history.Pipeline.Script + let promptForContinuation = defaultArg promptForContinuation false + + let table = Table() + table.ShowHeaders <- false + table.AddColumns("", "") |> ignore + table.AddRow("Script", $"[green]{scriptFile}[/]") |> ignore + table.AddRow("Pipeline", $"[bold green]{history.Pipeline.Name}[/]") |> ignore + table.AddRow("WorkingDir", $"[green]{scriptDir}[/]") |> ignore + table.AddRow("Arguments", history.Args) |> ignore + + AnsiConsole.Write table + AnsiConsole.MarkupLine("New arguments (leave empty to use the one from history):") + + let args = + if withLastArgs then + history.Args + else + let text = TextPrompt("> ") + text.AllowEmpty <- true + let newArgs = AnsiConsole.Prompt text + if String.IsNullOrEmpty newArgs then history.Args else newArgs + + AnsiConsole.MarkupLine("") + + let currentItems = continuationItems |> Option.defaultWith (fun () -> Collections.Generic.List()) + currentItems.Add({ history with Args = args }) + + if promptForContinuation && not withLastArgs then + if AnsiConsole.Confirm("Do you want to select another history to continue?", defaultValue = false) then + match History.PromptSelect() with + | ValueSome item -> do! History.Run(ctx, item, continuationItems = currentItems, promptForContinuation = true) + | _ -> () AnsiConsole.MarkupLine("") - let currentItems = continuationItems |> Option.defaultWith (fun () -> Collections.Generic.List()) - currentItems.Add({ history with Args = args }) - - if promptForContinuation && not withLastArgs then - if AnsiConsole.Confirm("Do you want to select another history to continue?", defaultValue = false) then - match History.PromptSelect() with - | ValueSome item -> do! History.Run(ctx, item, continuationItems = currentItems, promptForContinuation = true) - | _ -> () - AnsiConsole.MarkupLine("") - - // If there is no continuation commands need to fill, then we consider it should be executed now - if continuationItems.IsNone then - for index, history in Seq.indexed currentItems do - let scriptFile = Path.GetFileName history.Pipeline.Script - let scriptDir = Path.GetDirectoryName history.Pipeline.Script - let isHelpCommand = history.Args.Trim().ToLower() = "-h" - - if not isHelpCommand then - History.Add { history with StartedTime = DateTime.Now } - - if index = 0 then - AnsiConsole.Console.MarkupLine("[green]Start executing...[/]") - else - AnsiConsole.Console.MarkupLine("[green]Continue executing...[/]") - AnsiConsole.Console.WriteLine() - - try - do! - ctx.RunCommand($"dotnet fsi \"{scriptFile}\" -- -p {history.Pipeline.Name} {history.Args}", workingDir = scriptDir) - |> Async.map (ignore >> Ok) - with _ -> - () - - if currentItems.Count = 1 && isHelpCommand then do! History.Run(ctx, history) - } + // If there is no continuation commands need to fill, then we consider it should be executed now + if continuationItems.IsNone then + for index, history in Seq.indexed currentItems do + let scriptFile = Path.GetFileName history.Pipeline.Script + let scriptDir = Path.GetDirectoryName history.Pipeline.Script + let isHelpCommand = history.Args.Trim().ToLower() = "-h" + + if not isHelpCommand then + History.Add { history with StartedTime = DateTime.Now } + + if index = 0 then + AnsiConsole.Console.MarkupLine("[green]Start executing...[/]") + else + AnsiConsole.Console.MarkupLine("[green]Continue executing...[/]") + AnsiConsole.Console.WriteLine() + + try + do! + ctx.RunCommand($"dotnet fsi \"{scriptFile}\" -- -p {history.Pipeline.Name} {history.Args}", workingDir = scriptDir) + |> Async.map (ignore >> Ok) + with _ -> + () + + if currentItems.Count = 1 && isHelpCommand then do! History.Run(ctx, history) + } diff --git a/Fun.Build.Cli/Pipeline.fs b/Fun.Build.Cli/Pipeline.fs index 0071aef..bebdcff 100644 --- a/Fun.Build.Cli/Pipeline.fs +++ b/Fun.Build.Cli/Pipeline.fs @@ -157,68 +157,66 @@ type Pipeline with pipeline: Pipeline, ?promptForContinuation: bool, ?continuationCommands: Collections.Generic.List<{| Pipeline: Pipeline; Args: string |}> - ) = - asyncResult { - let promtForContinuation = defaultArg promptForContinuation false - let scriptFile = Path.GetFileName pipeline.Script - let scriptDir = Path.GetDirectoryName pipeline.Script + ) = asyncResult { + let promtForContinuation = defaultArg promptForContinuation false + let scriptFile = Path.GetFileName pipeline.Script + let scriptDir = Path.GetDirectoryName pipeline.Script - let table = Table() - table.ShowHeaders <- false - table.AddColumns("", "") |> ignore - table.AddRow("Script", $"[green]{scriptFile}[/]") |> ignore - table.AddRow("Pipeline", $"[bold green]{pipeline.Name}[/]") |> ignore - table.AddRow("WorkingDir", $"[green]{scriptDir}[/]") |> ignore - if String.IsNullOrEmpty(pipeline.Description.Trim()) |> not then - table.AddRow("Description", $"[green]{pipeline.Description}[/]") |> ignore + let table = Table() + table.ShowHeaders <- false + table.AddColumns("", "") |> ignore + table.AddRow("Script", $"[green]{scriptFile}[/]") |> ignore + table.AddRow("Pipeline", $"[bold green]{pipeline.Name}[/]") |> ignore + table.AddRow("WorkingDir", $"[green]{scriptDir}[/]") |> ignore + if String.IsNullOrEmpty(pipeline.Description.Trim()) |> not then + table.AddRow("Description", $"[green]{pipeline.Description}[/]") |> ignore - AnsiConsole.Write table + AnsiConsole.Write table - let textPrompt = TextPrompt("Arguments (-h for help): ") - textPrompt.AllowEmpty <- true - let args = AnsiConsole.Prompt textPrompt + let textPrompt = TextPrompt("Arguments (-h for help): ") + textPrompt.AllowEmpty <- true + let args = AnsiConsole.Prompt textPrompt - let currentCommands = continuationCommands |> Option.defaultWith (fun () -> Collections.Generic.List()) - currentCommands.Add({| Pipeline = pipeline; Args = args |}) + let currentCommands = continuationCommands |> Option.defaultWith (fun () -> Collections.Generic.List()) + currentCommands.Add({| Pipeline = pipeline; Args = args |}) + AnsiConsole.MarkupLine("") + + if promtForContinuation then + if AnsiConsole.Confirm("Do you want to select another pipeline to continue?", defaultValue = false) then + match Pipeline.PromptSelect() with + | ValueSome item -> do! Pipeline.Run(ctx, item, continuationCommands = currentCommands, promptForContinuation = true) + | _ -> () AnsiConsole.MarkupLine("") - if promtForContinuation then - if AnsiConsole.Confirm("Do you want to select another pipeline to continue?", defaultValue = false) then - match Pipeline.PromptSelect() with - | ValueSome item -> do! Pipeline.Run(ctx, item, continuationCommands = currentCommands, promptForContinuation = true) - | _ -> () - AnsiConsole.MarkupLine("") - - if continuationCommands.IsNone then - for index, command in Seq.indexed currentCommands do - let scriptFile = Path.GetFileName command.Pipeline.Script - let scriptDir = Path.GetDirectoryName command.Pipeline.Script - let isHelpCommand = command.Args.Trim().ToLower() = "-h" - - if not isHelpCommand then - History.Add - { - Pipeline = pipeline - Args = args - StartedTime = DateTime.Now - } - - if index = 0 then - AnsiConsole.Console.MarkupLine("[green]Start executing...[/]") - else - AnsiConsole.Console.MarkupLine("[green]Continue executing...[/]") - AnsiConsole.Console.WriteLine() - - try - do! - ctx.RunCommand($"dotnet fsi \"{scriptFile}\" -- -p {command.Pipeline.Name} {command.Args}", workingDir = scriptDir) - |> Async.map (ignore >> Ok) - with _ -> - () - - if isHelpCommand && currentCommands.Count = 1 then - do! Pipeline.Run(ctx, pipeline) - } + if continuationCommands.IsNone then + for index, command in Seq.indexed currentCommands do + let scriptFile = Path.GetFileName command.Pipeline.Script + let scriptDir = Path.GetDirectoryName command.Pipeline.Script + let isHelpCommand = command.Args.Trim().ToLower() = "-h" + + if not isHelpCommand then + History.Add { + Pipeline = pipeline + Args = args + StartedTime = DateTime.Now + } + + if index = 0 then + AnsiConsole.Console.MarkupLine("[green]Start executing...[/]") + else + AnsiConsole.Console.MarkupLine("[green]Continue executing...[/]") + AnsiConsole.Console.WriteLine() + + try + do! + ctx.RunCommand($"dotnet fsi \"{scriptFile}\" -- -p {command.Pipeline.Name} {command.Args}", workingDir = scriptDir) + |> Async.map (ignore >> Ok) + with _ -> + () + + if isHelpCommand && currentCommands.Count = 1 then + do! Pipeline.Run(ctx, pipeline) + } diff --git a/Fun.Build.Tests/ConditionsBuilderTests.fs b/Fun.Build.Tests/ConditionsBuilderTests.fs index 4aa2eae..b7b6135 100644 --- a/Fun.Build.Tests/ConditionsBuilderTests.fs +++ b/Fun.Build.Tests/ConditionsBuilderTests.fs @@ -9,386 +9,320 @@ open Fun.Build.PipelineContextExtensionsInternal [] let ``whenCmd should work`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmd { name "test1" } - run call - } - runImmediate - } - ) - - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1" ] - stage "" { - whenCmd { name "test1" } - run call - } - runImmediate - } - ) - - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1"; "v1" ] - stage "" { + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenCmd { name "test1" } + run call + } + runImmediate + }) + + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1" ] + stage "" { + whenCmd { name "test1" } + run call + } + runImmediate + }) + + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1"; "v1" ] + stage "" { + whenCmd { + name "test1" + acceptValues [ "v1"; "v2" ] + } + run call + } + runImmediate + }) + + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "-t"; "v1" ] + stage "" { + whenAll { whenCmd { name "test1" + alias "-t" acceptValues [ "v1"; "v2" ] } - run call - } - runImmediate - } - ) - - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "-t"; "v1" ] - stage "" { - whenAll { - whenCmd { - name "test1" - alias "-t" - acceptValues [ "v1"; "v2" ] - } - } - run call } - runImmediate + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmd { - name "test1" - optional - } - run call + shouldBeCalled (fun call -> pipeline "" { + stage "" { + whenCmd { + name "test1" + optional } - runImmediate + run call } - ) + runImmediate + }) [] let ``whenCmdArg should work`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmdArg "test1" - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenCmdArg "test1" + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1" ] - stage "" { - whenCmdArg "test1" - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1" ] + stage "" { + whenCmdArg "test1" + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmdArg "test1" "value" "description" true - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { + whenCmdArg "test1" "value" "description" true + run call } - ) + runImmediate + }) - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmdArg "test1" "value" "description" false - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenCmdArg "test1" "value" "description" false + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1"; "value" ] - stage "" { - whenCmdArg "test1" "value" "description" false - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1"; "value" ] + stage "" { + whenCmdArg "test1" "value" "description" false + run call } - ) + runImmediate + }) [] let ``whenEnv should work`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenEnv { name "test1" } - run call - } - runImmediate - } - ) - - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "test1", "" ] - stage "" { - whenEnv { name "test1" } - run call - } - runImmediate - } - ) - - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "test1", "v1" ] - stage "" { + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenEnv { name "test1" } + run call + } + runImmediate + }) + + shouldBeCalled (fun call -> pipeline "" { + envVars [ "test1", "" ] + stage "" { + whenEnv { name "test1" } + run call + } + runImmediate + }) + + shouldBeCalled (fun call -> pipeline "" { + envVars [ "test1", "v1" ] + stage "" { + whenEnv { + name "test1" + acceptValues [ "v1"; "v2" ] + } + run call + } + runImmediate + }) + + shouldBeCalled (fun call -> pipeline "" { + envVars [ "test1", "v1" ] + stage "" { + whenAll { whenEnv { name "test1" acceptValues [ "v1"; "v2" ] } - run call - } - runImmediate - } - ) - - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "test1", "v1" ] - stage "" { - whenAll { - whenEnv { - name "test1" - acceptValues [ "v1"; "v2" ] - } - } - run call } - runImmediate + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenEnv { - name "test1" - optional - } - run call + shouldBeCalled (fun call -> pipeline "" { + stage "" { + whenEnv { + name "test1" + optional } - runImmediate + run call } - ) + runImmediate + }) [] let ``whenEnvVar should work`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenEnvVar "test1" - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenEnvVar "test1" + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "test1", "" ] - stage "" { - whenEnvVar "test1" - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + envVars [ "test1", "" ] + stage "" { + whenEnvVar "test1" + run call } - ) + runImmediate + }) [] let ``when' stage should use stage execution result as when' condition for stage`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - when' (stage "" { run (fun ctx -> 1) }) - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + when' (stage "" { run (fun ctx -> 1) }) + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - when' (stage "" { run (fun ctx -> 0) }) - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { + when' (stage "" { run (fun ctx -> 0) }) + run call } - ) + runImmediate + }) - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenStage "" { run (fun _ -> 1) } - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenStage "" { run (fun _ -> 1) } + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenStage "" { run (fun _ -> 0) } - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { + whenStage "" { run (fun _ -> 0) } + run call } - ) + runImmediate + }) [] let ``when' stage should have parent context in execution mode`` () = - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "ENV", "0" ] - stage "" { - when' (stage "" { run (fun ctx -> ctx.GetEnvVar("ENV") |> int) }) - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + envVars [ "ENV", "0" ] + stage "" { + when' (stage "" { run (fun ctx -> ctx.GetEnvVar("ENV") |> int) }) + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "ENV", "0" ] - stage "" { - whenStage "" { run (fun ctx -> ctx.GetEnvVar("ENV") |> int) } - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + envVars [ "ENV", "0" ] + stage "" { + whenStage "" { run (fun ctx -> ctx.GetEnvVar("ENV") |> int) } + run call } - ) + runImmediate + }) [] let ``when' stage should use stage execution result as when' condition for nested stage`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - stage "nested" { - when' (stage "" { run (fun ctx -> 1) }) - run call - } + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + stage "nested" { + when' (stage "" { run (fun ctx -> 1) }) + run call } - runImmediate } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - stage "nested" { - when' (stage "" { run (fun ctx -> 0) }) - run call - } + shouldBeCalled (fun call -> pipeline "" { + stage "" { + stage "nested" { + when' (stage "" { run (fun ctx -> 0) }) + run call } - runImmediate } - ) + runImmediate + }) [] let ``when' stage should use stage execution result as when' condition in composed when`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenAll { when' (stage "" { run (fun ctx -> 1) }) } - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenAll { when' (stage "" { run (fun ctx -> 1) }) } + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenAll { when' (stage "" { run (fun ctx -> 0) }) } - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { + whenAll { when' (stage "" { run (fun ctx -> 0) }) } + run call } - ) + runImmediate + }) - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenAll { whenStage "" { run (fun _ -> 1) } } - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenAll { whenStage "" { run (fun _ -> 1) } } + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenAll { whenStage "" { run (fun _ -> 0) } } - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { + whenAll { whenStage "" { run (fun _ -> 0) } } + run call } - ) + runImmediate + }) [] let ``when' stage should work in pipeline directly`` () = - Assert.Throws(fun _ -> - pipeline "" { - when' (stage "" { run (fun _ -> 1) }) - runImmediate - } - ) + Assert.Throws(fun _ -> pipeline "" { + when' (stage "" { run (fun _ -> 1) }) + runImmediate + }) |> ignore - shouldBeCalled (fun call -> - pipeline "" { - when' (stage "" { run (fun _ -> 0) }) - stage "" { run call } - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + when' (stage "" { run (fun _ -> 0) }) + stage "" { run call } + runImmediate + }) - Assert.Throws(fun _ -> - pipeline "" { - whenStage "" { run (fun _ -> 1) } - runImmediate - } - ) + Assert.Throws(fun _ -> pipeline "" { + whenStage "" { run (fun _ -> 1) } + runImmediate + }) |> ignore - shouldBeCalled (fun call -> - pipeline "" { - whenStage "" { run (fun _ -> 0) } - stage "" { run call } - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + whenStage "" { run (fun _ -> 0) } + stage "" { run call } + runImmediate + }) [] let ``whenAny should work`` () = @@ -399,20 +333,23 @@ let ``whenAny should work`` () = let pipeline = PipelineContext.Create "" - { StageContext.Create "" with - ParentContext = pipeline |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = pipeline |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.True - { StageContext.Create "" with - ParentContext = { pipeline with EnvVars = Map.ofList [ "test2", "" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with EnvVars = Map.ofList [ "test2", "" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.True @@ -429,15 +366,17 @@ let ``whenAny should work`` () = } // All commands are optional, so it should return true when no command is provided - { StageContext.Create "" with - ParentContext = pipeline |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = pipeline |> StageParent.Pipeline |> ValueSome } |> condition2.Invoke |> Assert.True // All commands are optional, so it should return true if one command is provided - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test1"; "v1" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test1"; "v1" ] } |> StageParent.Pipeline |> ValueSome } |> condition2.Invoke |> Assert.True @@ -454,15 +393,17 @@ let ``whenAny should work`` () = } // Because at least one command is optional it should return true when no command is provided - { StageContext.Create "" with - ParentContext = pipeline |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = pipeline |> StageParent.Pipeline |> ValueSome } |> condition3.Invoke |> Assert.True // It should return true if a non-optional command is provided - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome } |> condition3.Invoke |> Assert.True @@ -479,32 +420,37 @@ let ``whenAll should work`` () = let pipeline = PipelineContext.Create "" - { StageContext.Create "" with - ParentContext = pipeline |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = pipeline |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = { pipeline with EnvVars = Map.ofList [ "test2", "" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with EnvVars = Map.ofList [ "test2", "" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = - { pipeline with - CmdArgs = [ "test1" ] - EnvVars = Map.ofList [ "test2", "" ] - } - |> StageParent.Pipeline - |> ValueSome + { + StageContext.Create "" with + ParentContext = + { + pipeline with + CmdArgs = [ "test1" ] + EnvVars = Map.ofList [ "test2", "" ] + } + |> StageParent.Pipeline + |> ValueSome } |> condition.Invoke |> Assert.True @@ -519,32 +465,37 @@ let ``whenNot should work`` () = let pipeline = PipelineContext.Create "" - { StageContext.Create "" with - ParentContext = pipeline |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = pipeline |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.True - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = { pipeline with EnvVars = Map.ofList [ "test2", "" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with EnvVars = Map.ofList [ "test2", "" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = - { pipeline with - CmdArgs = [ "test1" ] - EnvVars = Map.ofList [ "test2", "" ] - } - |> StageParent.Pipeline - |> ValueSome + { + StageContext.Create "" with + ParentContext = + { + pipeline with + CmdArgs = [ "test1" ] + EnvVars = Map.ofList [ "test2", "" ] + } + |> StageParent.Pipeline + |> ValueSome } |> condition.Invoke |> Assert.False @@ -567,26 +518,30 @@ let ``when compose should work`` () = let pipeline = PipelineContext.Create "" - { StageContext.Create "" with - ParentContext = pipeline |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = pipeline |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test1" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.True - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test2" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test2" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.True - { StageContext.Create "" with - ParentContext = { pipeline with CmdArgs = [ "test3" ] } |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = { pipeline with CmdArgs = [ "test3" ] } |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False @@ -626,8 +581,9 @@ let ``condition builder should follow the sequence`` () = ) } - { StageContext.Create "" with - ParentContext = PipelineContext.Create "" |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = PipelineContext.Create "" |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False @@ -660,8 +616,9 @@ let ``condition builder should follow the sequence`` () = ) } - { StageContext.Create "" with - ParentContext = PipelineContext.Create "" |> StageParent.Pipeline |> ValueSome + { + StageContext.Create "" with + ParentContext = PipelineContext.Create "" |> StageParent.Pipeline |> ValueSome } |> condition.Invoke |> Assert.False @@ -671,42 +628,38 @@ let ``condition builder should follow the sequence`` () = [] let ``for top level condition of stage or pipeline it should combine all condition with && rule`` () = - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "-t" ] - envVars [ "ENV", "" ] + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "-t" ] + envVars [ "ENV", "" ] + + when' true + whenCmdArg "-t" + whenEnvVar "ENV" + stage "" { when' true whenCmdArg "-t" whenEnvVar "ENV" - stage "" { - when' true - whenCmdArg "-t" - whenEnvVar "ENV" + run (fun _ -> call ()) + } - run (fun _ -> call ()) - } + runImmediate + }) - runImmediate - } - ) + shouldNotBeCalled (fun call -> pipeline "" { + cmdArgs [ "-t" ] - shouldNotBeCalled (fun call -> - pipeline "" { - cmdArgs [ "-t" ] + when' true + whenCmdArg "-t" + stage "" { when' true whenCmdArg "-t" + whenEnvVar "ENV" - stage "" { - when' true - whenCmdArg "-t" - whenEnvVar "ENV" - - run (fun _ -> call ()) - } - - runImmediate + run (fun _ -> call ()) } - ) + + runImmediate + }) diff --git a/Fun.Build.Tests/ExceptionHandlingTests.fs b/Fun.Build.Tests/ExceptionHandlingTests.fs index 5139db9..2b66c97 100644 --- a/Fun.Build.Tests/ExceptionHandlingTests.fs +++ b/Fun.Build.Tests/ExceptionHandlingTests.fs @@ -9,18 +9,16 @@ let ``exception handling should work for sync steps`` () = let mutable exn = ValueNone try - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - run (fun _ -> - failwith "test" - () - ) - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + run (fun _ -> + failwith "test" + () + ) + run call } - ) + runImmediate + }) with :? PipelineFailedException as ex -> exn <- ValueSome ex @@ -33,25 +31,23 @@ let ``exception handling should work for parallel steps`` () = let mutable exn = ValueNone try - shouldNotBeCalled (fun call -> - pipeline "" { + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + paralle + run (fun _ -> async { + do! Async.Sleep 10 + failwith "test" + () + }) stage "" { - paralle run (fun _ -> async { - do! Async.Sleep 10 - failwith "test" - () + do! Async.Sleep 100 + call () }) - stage "" { - run (fun _ -> async { - do! Async.Sleep 100 - call () - }) - } } - runImmediate } - ) + runImmediate + }) with :? PipelineFailedException as ex -> exn <- ValueSome ex @@ -64,19 +60,17 @@ let ``PipelineFailedException should fail fast`` () = let mutable exn = ValueNone try - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - continueOnStepFailure - run (fun _ -> - raise (PipelineFailedException("demo")) - () - ) - } - post [ stage "" { run call } ] - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + continueOnStepFailure + run (fun _ -> + raise (PipelineFailedException("demo")) + () + ) } - ) + post [ stage "" { run call } ] + runImmediate + }) with :? PipelineFailedException as ex -> exn <- ValueSome ex @@ -89,19 +83,17 @@ let ``PipelineCancelledException should fail fast`` () = let mutable exn = ValueNone try - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - continueOnStepFailure - run (fun _ -> - raise (PipelineCancelledException("demo")) - () - ) - } - post [ stage "" { run call } ] - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + continueOnStepFailure + run (fun _ -> + raise (PipelineCancelledException("demo")) + () + ) } - ) + post [ stage "" { run call } ] + runImmediate + }) with :? PipelineCancelledException as ex -> exn <- ValueSome ex diff --git a/Fun.Build.Tests/PipelineBuilderTests.fs b/Fun.Build.Tests/PipelineBuilderTests.fs index 914b832..54e5b73 100644 --- a/Fun.Build.Tests/PipelineBuilderTests.fs +++ b/Fun.Build.Tests/PipelineBuilderTests.fs @@ -8,133 +8,113 @@ open System.Threading.Tasks [] let ``pipeline should world with multiple stages with different conditions`` () = - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmdArg "test1" - run call - } - stage "" { - whenEnvVar "test2" - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenCmdArg "test1" + run call } - ) + stage "" { + whenEnvVar "test2" + run call + } + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1" ] - stage "" { - whenCmdArg "test1" - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1" ] + stage "" { + whenCmdArg "test1" + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - envVars [ "test2", "" ] - stage "" { - whenEnvVar "test2" - run call - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + envVars [ "test2", "" ] + stage "" { + whenEnvVar "test2" + run call } - ) + runImmediate + }) - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenAll { - cmdArg "test1" - envVar "test2" - } - run call + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenAll { + cmdArg "test1" + envVar "test2" } - runImmediate + run call } - ) + runImmediate + }) - shouldNotBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1" ] - stage "" { - whenAll { - cmdArg "test1" - envVar "test2" - } - run call + shouldNotBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1" ] + stage "" { + whenAll { + cmdArg "test1" + envVar "test2" } - runImmediate + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1" ] - envVars [ "test2", "" ] - stage "" { - timeout 1000 - whenAll { - cmdArg "test1" - envVar "test2" - } - timeout 1000 - run call + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1" ] + envVars [ "test2", "" ] + stage "" { + timeout 1000 + whenAll { + cmdArg "test1" + envVar "test2" } - runImmediate + timeout 1000 + run call } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { run call } - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + stage "" { run call } + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - stage "" { run ignore } - stage "" { run call } - stage "" { - paralle - run "dotnet --version" - run "dotnet --list-sdks" - } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { run ignore } + stage "" { run call } + stage "" { + paralle + run "dotnet --version" + run "dotnet --list-sdks" } - ) + runImmediate + }) [] let ``post stage should always run when other stage is failed`` () = Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "" { - stage "" { - run (fun _ -> - failwith "test" - () - ) - } - post [ stage "" { run call } ] - runImmediate + shouldBeCalled (fun call -> pipeline "" { + stage "" { + run (fun _ -> + failwith "test" + () + ) } - ) + post [ stage "" { run call } ] + runImmediate + }) ) |> ignore Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "" { - stage "" { run (fun _ -> -1) } - post [ stage "" { run call } ] - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + stage "" { run (fun _ -> -1) } + post [ stage "" { run call } ] + runImmediate + }) ) |> ignore @@ -143,58 +123,48 @@ let ``post stage should always run when other stage is failed`` () = [] let ``all post stages should always run when some post stages are failed`` () = Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "" { - post [ - stage "" { - run (fun _ -> - failwith "test" - () - ) - } - stage "" { run call } - ] - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + post [ + stage "" { + run (fun _ -> + failwith "test" + () + ) + } + stage "" { run call } + ] + runImmediate + }) ) |> ignore Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "" { - post [ stage "" { run (fun _ -> -1) }; stage "" { run call } ] - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + post [ stage "" { run (fun _ -> -1) }; stage "" { run call } ] + runImmediate + }) ) |> ignore [] let ``runIfOnlySpecified should work`` () = - shouldBeCalled (fun call -> - pipeline "demo" { - cmdArgs [ "-p"; "demo" ] - stage "" { run call } - runIfOnlySpecified - } - ) + shouldBeCalled (fun call -> pipeline "demo" { + cmdArgs [ "-p"; "demo" ] + stage "" { run call } + runIfOnlySpecified + }) - shouldNotBeCalled (fun call -> - pipeline "demo" { - stage "" { run call } - runIfOnlySpecified - } - ) + shouldNotBeCalled (fun call -> pipeline "demo" { + stage "" { run call } + runIfOnlySpecified + }) - shouldBeCalled (fun call -> - pipeline "demo" { - timeout 1 - stage "" { run call } - runIfOnlySpecified false - } - ) + shouldBeCalled (fun call -> pipeline "demo" { + timeout 1 + stage "" { run call } + runIfOnlySpecified false + }) [] let ``runIfOnlySpecified should work for multiple -p`` () = @@ -239,7 +209,11 @@ let ``parallel should work`` () = } runImmediate } - Assert.InRange(sw.ElapsedMilliseconds, 1000, 2000) + // Upper bounds are deliberately loose: these are wall-clock assertions and CI runners + // are shared and slow. What each bound encodes: + // parallel - must stay well under the 3000ms the sequential version needs + // sequential - must be at least 2500ms, which is what proves it did not run in parallel + Assert.InRange(sw.ElapsedMilliseconds, 1000, 2400) sw.Restart() pipeline "" { @@ -251,7 +225,7 @@ let ``parallel should work`` () = runImmediate } let elapsed = sw.ElapsedMilliseconds - Assert.InRange(elapsed, 2500, 4000) + Assert.InRange(elapsed, 2500, 10000) [] @@ -324,167 +298,141 @@ let ``Syntax check`` () = [] let ``Verification should work`` () = Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { - whenCmdArg "123" - stage "" { run call } - post [ stage "" { run call } ] - runImmediate - } - ) + shouldNotBeCalled (fun call -> pipeline "" { + whenCmdArg "123" + stage "" { run call } + post [ stage "" { run call } ] + runImmediate + }) ) |> ignore Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { - verify (fun _ -> false) - stage "" { run call } - post [ stage "" { run call } ] - runImmediate - } - ) - ) - |> ignore - - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "123" ] - whenAll { cmdArg "123" } + shouldNotBeCalled (fun call -> pipeline "" { + verify (fun _ -> false) stage "" { run call } post [ stage "" { run call } ] runImmediate - } + }) ) + |> ignore + + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "123" ] + whenAll { cmdArg "123" } + stage "" { run call } + post [ stage "" { run call } ] + runImmediate + }) [] let ``when' stage should use stage execution result as verification condition for pipeline`` () = let mutable numChecks = 0 Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { - when' ( - stage "whenStageShouldFail" { - run (fun ctx -> - numChecks <- numChecks + 1 - 1 - ) - } - ) - stage "" { run call } - runImmediate - } - ) + shouldNotBeCalled (fun call -> pipeline "" { + when' ( + stage "whenStageShouldFail" { + run (fun ctx -> + numChecks <- numChecks + 1 + 1 + ) + } + ) + stage "" { run call } + runImmediate + }) ) |> ignore Assert.Equal(1, numChecks) - shouldBeCalled (fun call -> - pipeline "" { - when' (stage "whenStageShouldPass" { run (fun ctx -> 0) }) - stage "" { run call } - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + when' (stage "whenStageShouldPass" { run (fun ctx -> 0) }) + stage "" { run call } + runImmediate + }) [] let ``Should fail if stage is ignored`` () = Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { - stage "" { - whenCmdArg "123" - failIfIgnored - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { + whenCmdArg "123" + failIfIgnored + run call } - ) + runImmediate + }) ) |> ignore Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { + shouldNotBeCalled (fun call -> pipeline "" { + stage "" { stage "" { - stage "" { - whenCmdArg "123" - failIfIgnored - run call - } - } - runImmediate - } - ) - ) - |> ignore - - shouldBeCalled (fun call -> - pipeline "1" { - cmdArgs [ "123" ] - stage "2" { - stage "3" { whenCmdArg "123" failIfIgnored run call } } runImmediate - } + }) ) + |> ignore + + shouldBeCalled (fun call -> pipeline "1" { + cmdArgs [ "123" ] + stage "2" { + stage "3" { + whenCmdArg "123" + failIfIgnored + run call + } + } + runImmediate + }) [] let ``whenCmdArg should work`` () = Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { - whenCmdArg "test1" - stage "" { run call } - runImmediate - } - ) - ) - |> ignore - - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1" ] + shouldNotBeCalled (fun call -> pipeline "" { whenCmdArg "test1" stage "" { run call } runImmediate - } + }) ) + |> ignore - shouldBeCalled (fun call -> - pipeline "" { - whenCmdArg "test1" "value" "description" true - stage "" { run call } - runImmediate - } - ) + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1" ] + whenCmdArg "test1" + stage "" { run call } + runImmediate + }) - Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "" { - whenCmdArg "test1" "value" "description" false - stage "" { run call } - runImmediate - } - ) - ) - |> ignore + shouldBeCalled (fun call -> pipeline "" { + whenCmdArg "test1" "value" "description" true + stage "" { run call } + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { - cmdArgs [ "test1"; "value" ] + Assert.Throws(fun _ -> + shouldNotBeCalled (fun call -> pipeline "" { whenCmdArg "test1" "value" "description" false stage "" { run call } runImmediate - } + }) ) + |> ignore + + shouldBeCalled (fun call -> pipeline "" { + cmdArgs [ "test1"; "value" ] + whenCmdArg "test1" "value" "description" false + stage "" { run call } + runImmediate + }) diff --git a/Fun.Build.Tests/SequenceTests.fs b/Fun.Build.Tests/SequenceTests.fs index 5d56575..2d29f12 100644 --- a/Fun.Build.Tests/SequenceTests.fs +++ b/Fun.Build.Tests/SequenceTests.fs @@ -136,36 +136,33 @@ let ``nested stages should run in sequence`` () = Assert.Equal([ 1; 2; 3; 4; 5; 6; 7 ], calls) [] -let ``custom exit code should pass for stage`` () = - pipeline "" { - stage "" { - acceptExitCodes [| 99 |] - run (fun _ -> async { return 99 }) - } - runImmediate +let ``custom exit code should pass for stage`` () = pipeline "" { + stage "" { + acceptExitCodes [| 99 |] + run (fun _ -> async { return 99 }) } + runImmediate +} [] -let ``custom exit code should pass for pipeline`` () = - pipeline "" { - acceptExitCodes [| 99 |] - stage "" { run (fun _ -> async { return 99 }) } - runImmediate - } +let ``custom exit code should pass for pipeline`` () = pipeline "" { + acceptExitCodes [| 99 |] + stage "" { run (fun _ -> async { return 99 }) } + runImmediate +} [] -let ``custom exit code should pass for nested stage`` () = - pipeline "" { - stage "" { - acceptExitCodes [| 99 |] - stage "nested" { run (fun _ -> async { return 99 }) } - stage "nested2" { - acceptExitCodes [ 123 ] - run (fun _ -> 123) - } +let ``custom exit code should pass for nested stage`` () = pipeline "" { + stage "" { + acceptExitCodes [| 99 |] + stage "nested" { run (fun _ -> async { return 99 }) } + stage "nested2" { + acceptExitCodes [ 123 ] + run (fun _ -> 123) } - runImmediate } + runImmediate +} [] @@ -223,91 +220,81 @@ let ``continueOnStepFailure should work`` () = } Assert.Equal([ 1; 2; 3; 4 ], list) - shouldBeCalled (fun fn -> - pipeline "" { - stage "" { - paralle - continueOnStepFailure - run (fun _ -> Ok()) - run (fun _ -> Error "") - run (fun _ -> Ok()) - } - stage "" { run fn } - runImmediate + shouldBeCalled (fun fn -> pipeline "" { + stage "" { + paralle + continueOnStepFailure + run (fun _ -> Ok()) + run (fun _ -> Error "") + run (fun _ -> Ok()) } - ) + stage "" { run fn } + runImmediate + }) list.Clear() Assert.Throws(fun _ -> - shouldNotBeCalled (fun fn -> - pipeline "" { - stage "" { - continueOnStepFailure false - run (fun _ -> list.Add(1)) - run (fun _ -> - list.Add(2) - Error "" - ) - run (fun _ -> Ok()) - } - stage "" { run fn } - runImmediate + shouldNotBeCalled (fun fn -> pipeline "" { + stage "" { + continueOnStepFailure false + run (fun _ -> list.Add(1)) + run (fun _ -> + list.Add(2) + Error "" + ) + run (fun _ -> Ok()) } - ) + stage "" { run fn } + runImmediate + }) ) |> ignore Assert.Equal([ 1; 2 ], list) Assert.Throws(fun _ -> - shouldNotBeCalled (fun fn -> - pipeline "" { - stage "" { - paralle - continueOnStepFailure false - run (fun _ -> Ok()) - run (fun _ -> Error "") - run (fun _ -> Ok()) - } - stage "" { run fn } - runImmediate + shouldNotBeCalled (fun fn -> pipeline "" { + stage "" { + paralle + continueOnStepFailure false + run (fun _ -> Ok()) + run (fun _ -> Error "") + run (fun _ -> Ok()) } - ) + stage "" { run fn } + runImmediate + }) ) |> ignore list.Clear() Assert.Throws(fun _ -> - shouldNotBeCalled (fun fn -> - pipeline "" { - stage "" { - run (fun _ -> list.Add(1)) - run (fun _ -> - list.Add(2) - Error "" - ) - run (fun _ -> Ok()) - } - stage "" { run fn } - runImmediate + shouldNotBeCalled (fun fn -> pipeline "" { + stage "" { + run (fun _ -> list.Add(1)) + run (fun _ -> + list.Add(2) + Error "" + ) + run (fun _ -> Ok()) } - ) + stage "" { run fn } + runImmediate + }) ) |> ignore Assert.Equal([ 1; 2 ], list) Assert.Throws(fun _ -> - shouldNotBeCalled (fun fn -> - pipeline "" { - stage "" { - paralle - run (fun _ -> Ok()) - run (fun _ -> Error "") - run (fun _ -> Ok()) - } - stage "" { run fn } - runImmediate + shouldNotBeCalled (fun fn -> pipeline "" { + stage "" { + paralle + run (fun _ -> Ok()) + run (fun _ -> Error "") + run (fun _ -> Ok()) } - ) + stage "" { run fn } + runImmediate + }) ) |> ignore @@ -334,9 +321,27 @@ let ``continueOnStepFailure should work`` () = [] let ``continueStepsOnFailure should work`` () = let list = System.Collections.Generic.List() + Assert.Throws(fun _ -> pipeline "" { + stage "" { + continueStepsOnFailure + run (fun _ -> list.Add(1)) + run (fun _ -> + list.Add(2) + Error "" + ) + run (fun _ -> list.Add(3)) + } + stage "" { run (fun _ -> list.Add(4)) } + runImmediate + }) + |> ignore + Assert.Equal([ 1; 2; 3 ], list) + + list.Clear() Assert.Throws(fun _ -> - pipeline "" { + shouldNotBeCalled (fun fn -> pipeline "" { stage "" { + paralle continueStepsOnFailure run (fun _ -> list.Add(1)) run (fun _ -> @@ -345,76 +350,50 @@ let ``continueStepsOnFailure should work`` () = ) run (fun _ -> list.Add(3)) } - stage "" { run (fun _ -> list.Add(4)) } + stage "" { run fn } runImmediate - } + }) ) |> ignore - Assert.Equal([ 1; 2; 3 ], list) + Assert.Equal([ 1; 2; 3 ], Seq.sort list) list.Clear() - Assert.Throws(fun _ -> - shouldNotBeCalled (fun fn -> - pipeline "" { - stage "" { - paralle - continueStepsOnFailure - run (fun _ -> list.Add(1)) - run (fun _ -> - list.Add(2) - Error "" - ) - run (fun _ -> list.Add(3)) - } - stage "" { run fn } - runImmediate - } - ) - ) + Assert.Throws(fun _ -> pipeline "" { + stage "" { + continueStepsOnFailure false + run (fun _ -> list.Add(1)) + run (fun _ -> + list.Add(2) + Error "" + ) + run (fun _ -> list.Add(3)) + } + stage "" { run (fun _ -> list.Add(4)) } + runImmediate + }) |> ignore - Assert.Equal([ 1; 2; 3 ], Seq.sort list) + Assert.Equal([ 1; 2 ], list) list.Clear() Assert.Throws(fun _ -> - pipeline "" { + shouldNotBeCalled (fun fn -> pipeline "" { stage "" { + paralle continueStepsOnFailure false run (fun _ -> list.Add(1)) - run (fun _ -> + run (fun _ -> async { + do! Async.Sleep 1000 list.Add(2) - Error "" - ) - run (fun _ -> list.Add(3)) + return Error "" + }) + run (fun _ -> async { + do! Async.Sleep 3000 + list.Add(3) + }) } - stage "" { run (fun _ -> list.Add(4)) } + stage "" { run fn } runImmediate - } - ) - |> ignore - Assert.Equal([ 1; 2 ], list) - - list.Clear() - Assert.Throws(fun _ -> - shouldNotBeCalled (fun fn -> - pipeline "" { - stage "" { - paralle - continueStepsOnFailure false - run (fun _ -> list.Add(1)) - run (fun _ -> async { - do! Async.Sleep 1000 - list.Add(2) - return Error "" - }) - run (fun _ -> async { - do! Async.Sleep 3000 - list.Add(3) - }) - } - stage "" { run fn } - runImmediate - } - ) + }) ) |> ignore Assert.Equal([ 1; 2 ], list) @@ -461,46 +440,42 @@ let ``continueStageOnFailure should work`` () = Assert.Equal([ 1; 2; 4 ], Seq.sort list) list.Clear() - Assert.Throws(fun _ -> - pipeline "" { - stage "" { - continueStepsOnFailure true - continueStageOnFailure false - run (fun _ -> list.Add(1)) - run (fun _ -> - list.Add(2) - Error "" - ) - run (fun _ -> list.Add(3)) - } - stage "" { run (fun _ -> list.Add(4)) } - runImmediate + Assert.Throws(fun _ -> pipeline "" { + stage "" { + continueStepsOnFailure true + continueStageOnFailure false + run (fun _ -> list.Add(1)) + run (fun _ -> + list.Add(2) + Error "" + ) + run (fun _ -> list.Add(3)) } - ) + stage "" { run (fun _ -> list.Add(4)) } + runImmediate + }) |> ignore Assert.Equal([ 1; 2; 3 ], list) list.Clear() - Assert.Throws(fun _ -> - pipeline "" { - stage "" { - paralle - continueStepsOnFailure true - continueStageOnFailure false - run (fun _ -> list.Add(1)) - run (fun _ -> - list.Add(2) - Error "" - ) - run (fun _ -> async { - do! Async.Sleep 200 - list.Add(3) - }) - } - stage "" { run (fun _ -> list.Add(4)) } - runImmediate + Assert.Throws(fun _ -> pipeline "" { + stage "" { + paralle + continueStepsOnFailure true + continueStageOnFailure false + run (fun _ -> list.Add(1)) + run (fun _ -> + list.Add(2) + Error "" + ) + run (fun _ -> async { + do! Async.Sleep 200 + list.Add(3) + }) } - ) + stage "" { run (fun _ -> list.Add(4)) } + runImmediate + }) |> ignore Assert.Equal([ 1; 2; 3 ], Seq.sort list) @@ -508,23 +483,21 @@ let ``continueStageOnFailure should work`` () = [] let ``continueStepsOnFailure for nested stage should work`` () = let list = System.Collections.Generic.List() - Assert.Throws(fun _ -> - pipeline "" { + Assert.Throws(fun _ -> pipeline "" { + stage "" { + continueStepsOnFailure + run (fun _ -> list.Add(1)) stage "" { - continueStepsOnFailure - run (fun _ -> list.Add(1)) - stage "" { - run (fun _ -> - list.Add(2) - Error "" - ) - } - run (fun _ -> list.Add(3)) + run (fun _ -> + list.Add(2) + Error "" + ) } - stage "" { run (fun _ -> list.Add(4)) } - runImmediate + run (fun _ -> list.Add(3)) } - ) + stage "" { run (fun _ -> list.Add(4)) } + runImmediate + }) |> ignore Assert.Equal([ 1; 2; 3 ], list) @@ -550,26 +523,24 @@ let ``continueStepsOnFailure for nested stage should work`` () = [] let ``pipeline should be terminated correctly when parallel step are failed`` () = - Assert.Throws(fun _ -> - pipeline "" { + Assert.Throws(fun _ -> pipeline "" { + stage "" { + paralle + stage "timer" { + whenWindows + run (fun ctx -> ctx.RunCommand("timeout /t 10")) + } + stage "timer" { + whenNot { platformWindows } + run (fun ctx -> ctx.RunCommand("sleep 10")) + } stage "" { - paralle - stage "timer" { - whenWindows - run (fun ctx -> ctx.RunCommand("timeout /t 10")) - } - stage "timer" { - whenNot { platformWindows } - run (fun ctx -> ctx.RunCommand("sleep 10")) - } - stage "" { - run (Async.Sleep 1000) - run (fun _ -> - failwith "Demo" - () - ) - } + run (Async.Sleep 1000) + run (fun _ -> + failwith "Demo" + () + ) } - runImmediate } - ) + runImmediate + }) diff --git a/Fun.Build.Tests/StageContextExtensions.fs b/Fun.Build.Tests/StageContextExtensions.fs index 6766521..7f4f23a 100644 --- a/Fun.Build.Tests/StageContextExtensions.fs +++ b/Fun.Build.Tests/StageContextExtensions.fs @@ -1,5 +1,6 @@ module Fun.Build.Tests.StageContextExtensions +open System.Threading open Xunit open Fun.Build open Fun.Build.StageContextExtensionsInternal @@ -7,12 +8,11 @@ open Fun.Build.StageContextExtensionsInternal [] let ``TryGetCmdArgOrEnvVar should work`` () = - let pipeline = - pipeline "" { - envVars [ "test1", "e1"; "test2", "e1"; "test3", "e1" ] - cmdArgs [ "test3"; "c1" ] - stage "" { envVars [ "test2", "e2" ] } - } + let pipeline = pipeline "" { + envVars [ "test1", "e1"; "test2", "e1"; "test3", "e1" ] + cmdArgs [ "test3"; "c1" ] + stage "" { envVars [ "test2", "e2" ] } + } Assert.Equal(ValueNone, pipeline.Stages[0].TryGetCmdArgOrEnvVar "abc") Assert.Equal(ValueSome "e1", pipeline.Stages[0].TryGetCmdArgOrEnvVar "test1") @@ -22,11 +22,10 @@ let ``TryGetCmdArgOrEnvVar should work`` () = [] let ``GetAllEnvVars should work`` () = - let pipeline = - pipeline "" { - envVars [ "test1", "e1"; "test2", "e1"; "test3", "e3" ] - stage "" { envVars [ "test2", "e2" ] } - } + let pipeline = pipeline "" { + envVars [ "test1", "e1"; "test2", "e1"; "test3", "e3" ] + stage "" { envVars [ "test2", "e2" ] } + } let result = pipeline.Stages[0].GetAllEnvVars() Assert.Equal("e1", Map.find "test1" result) @@ -36,23 +35,21 @@ let ``GetAllEnvVars should work`` () = [] let ``GetAllCmdArgs should work`` () = - let pipeline = - pipeline "" { - cmdArgs [ "test3"; "c1" ] - stage "" { echo "" } - } + let pipeline = pipeline "" { + cmdArgs [ "test3"; "c1" ] + stage "" { echo "" } + } Assert.Equal([ "test3"; "c1" ], pipeline.Stages[0].GetAllCmdArgs()) [] let ``workingDir should work`` () = - let pipeline = - pipeline "" { - workingDir "test1" - stage "" { workingDir "test2" } - stage "" { run ignore } - } + let pipeline = pipeline "" { + workingDir "test1" + stage "" { workingDir "test2" } + stage "" { run ignore } + } Assert.Equal(ValueSome "test1", pipeline.Stages[1].GetWorkingDir()) Assert.Equal(ValueSome "test2", pipeline.Stages[0].GetWorkingDir()) @@ -60,21 +57,19 @@ let ``workingDir should work`` () = [] let ``noPrefixForStep should work`` () = - let pipeline1 = - pipeline "" { - noPrefixForStep false - stage "" { noPrefixForStep false } - stage "" { run ignore } - } + let pipeline1 = pipeline "" { + noPrefixForStep false + stage "" { noPrefixForStep false } + stage "" { run ignore } + } Assert.Equal(false, pipeline1.Stages[0].GetNoPrefixForStep()) Assert.Equal(false, pipeline1.Stages[1].GetNoPrefixForStep()) - let pipeline2 = - pipeline "" { - stage "" { noPrefixForStep false } - stage "" { run ignore } - } + let pipeline2 = pipeline "" { + stage "" { noPrefixForStep false } + stage "" { run ignore } + } Assert.Equal(false, pipeline2.Stages[0].GetNoPrefixForStep()) Assert.Equal(true, pipeline2.Stages[1].GetNoPrefixForStep()) @@ -82,171 +77,338 @@ let ``noPrefixForStep should work`` () = [] let ``noStdRedirectForStep should work`` () = - let pipeline1 = - pipeline "" { - noStdRedirectForStep - stage "" { noStdRedirectForStep } - stage "" { run ignore } - } + let pipeline1 = pipeline "" { + noStdRedirectForStep + stage "" { noStdRedirectForStep } + stage "" { run ignore } + } Assert.Equal(true, pipeline1.Stages[0].GetNoStdRedirectForStep()) Assert.Equal(true, pipeline1.Stages[1].GetNoStdRedirectForStep()) - let pipeline2 = - pipeline "" { - stage "" { noStdRedirectForStep } - stage "" { run ignore } - } + let pipeline2 = pipeline "" { + stage "" { noStdRedirectForStep } + stage "" { run ignore } + } Assert.Equal(true, pipeline2.Stages[0].GetNoStdRedirectForStep()) Assert.Equal(false, pipeline2.Stages[1].GetNoStdRedirectForStep()) [] -let ``RunCommandCaptureOutput should work`` () = - pipeline "" { +let ``RunCommandCaptureOutput should work`` () = pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureOutput "echo 42" + Assert.Equal(Ok "42\n", result) + }) + } + stage "" { + whenWindows + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureOutput "powershell echo 42" + Assert.Equal(Ok "42\r\n", result) + }) + } + runImmediate +} + +[] +let ``RunCommandCaptureOutput in working directory should work`` () = pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let tmpFolder = System.IO.Path.GetTempPath() + let! result = ctx.RunCommandCaptureOutput("echo 42", workingDir = tmpFolder) + Assert.Equal(Ok "42\n", result) + }) + } + stage "" { + whenWindows + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureOutput "powershell echo 42" + Assert.Equal(Ok "42\r\n", result) + }) + } + runImmediate +} + +[] +let ``RunCommandCaptureOutput should return an error if command failed`` () = + Assert.Throws(fun _ -> + shouldBeCalled (fun call -> pipeline "" { + stage "" { + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureOutput "thisCmdDoesNotExist" + return () + }) + } + runImmediate + }) + ) + |> ignore + + +[] +let ``RunCommandCaptureAll should return exit code, stdout and stderr`` () = + shouldBeCalled (fun call -> pipeline "" { stage "" { whenAny { platformOSX platformLinux } run (fun ctx -> async { - let! result = ctx.RunCommandCaptureOutput "echo 42" - Assert.Equal(Ok "42\n", result) + let! result = ctx.RunCommandCaptureAll "sh -c \"echo out; echo err >&2; exit 3\"" + Assert.Equal(3, result.ExitCode) + Assert.Equal("out\n", result.StandardOutput) + Assert.Equal("err\n", result.StandardError) + call () }) } stage "" { whenWindows run (fun ctx -> async { - let! result = ctx.RunCommandCaptureOutput "powershell echo 42" - Assert.Equal(Ok "42\r\n", result) + let! result = ctx.RunCommandCaptureAll "powershell -Command \"echo out; [Console]::Error.WriteLine('err'); exit 3\"" + Assert.Equal(3, result.ExitCode) + Assert.Equal("out\r\n", result.StandardOutput) + Assert.Equal("err\r\n", result.StandardError) + call () }) } runImmediate - } + }) [] -let ``RunCommandCaptureOutput in working directory should work`` () = - pipeline "" { +let ``RunCommandCaptureAll should not fail the stage on a non zero exit code`` () = + shouldBeCalled (fun call -> pipeline "" { stage "" { whenAny { platformOSX platformLinux } run (fun ctx -> async { - let tmpFolder = System.IO.Path.GetTempPath() - let! result = ctx.RunCommandCaptureOutput("echo 42", workingDir = tmpFolder) - Assert.Equal(Ok "42\n", result) + let! result = ctx.RunCommandCaptureAll "sh -c \"exit 1\"" + Assert.Equal(1, result.ExitCode) + call () }) } stage "" { whenWindows run (fun ctx -> async { - let! result = ctx.RunCommandCaptureOutput "powershell echo 42" - Assert.Equal(Ok "42\r\n", result) + let! result = ctx.RunCommandCaptureAll "powershell -Command \"exit 1\"" + Assert.Equal(1, result.ExitCode) + call () }) } runImmediate - } + }) [] -let ``RunCommandCaptureOutput should return an error if command failed`` () = - Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "" { +let ``RunSensitiveCommandCaptureAll should work`` () = + let secret = "SENSITIVE97VALUE" + let mutable stdout = "" + + shouldBeCalled (fun call -> + let out, _ = + captureConsole (fun () -> pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let! result = ctx.RunSensitiveCommandCaptureAll $"""echo {secret}""" + Assert.Equal(0, result.ExitCode) + // Captured output is the caller's own business, so it comes back unmasked. + Assert.Equal(secret + "\n", result.StandardOutput) + Assert.Equal("", result.StandardError) + call () + }) + } stage "" { + whenWindows run (fun ctx -> async { - let! result = ctx.RunCommandCaptureOutput "thisCmdDoesNotExist" - return () + let! result = ctx.RunSensitiveCommandCaptureAll $"""powershell echo {secret}""" + Assert.Equal(0, result.ExitCode) + Assert.Equal(secret + "\r\n", result.StandardOutput) + Assert.Equal("", result.StandardError) + call () }) } runImmediate - } - ) + }) + stdout <- out ) - |> ignore + + // Masking is the only thing this member does differently from RunCommandCaptureAll, and it was + // the one thing the original test never checked. + Assert.DoesNotContain(secret, stdout) +// The markers below wrap the secret without any whitespace so that the whole thing survives as a +// single token through both `sh -c` and `powershell -Command`, and so the assertion can prove the +// line was printed *and* that the secret inside it was replaced. [] -let ``RunCommandCaptureAll should return exit code, stdout and stderr`` () = - pipeline "" { - stage "" { - whenAny { - platformOSX - platformLinux - } - run (fun ctx -> async { - let! result = ctx.RunCommandCaptureAll "sh -c \"echo out; echo err >&2; exit 3\"" - Assert.Equal(3, result.ExitCode) - Assert.Equal("out\n", result.StandardOutput) - Assert.Equal("err\n", result.StandardError) - }) - } - stage "" { - whenWindows - run (fun ctx -> async { - let! result = ctx.RunCommandCaptureAll "powershell -Command \"echo out; [Console]::Error.WriteLine('err'); exit 3\"" - Assert.Equal(3, result.ExitCode) - Assert.Equal("out\r\n", result.StandardOutput) - Assert.Equal("err\r\n", result.StandardError) +let ``RunSensitiveCommand should mask the secret out of the child's own output`` () = + let secret = "SUPERSECRET95" + let mutable stdout = "" + let mutable stderr = "" + + shouldBeCalled (fun call -> + let out, err = + captureConsole (fun () -> pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + // disablePrintCommand keeps the (already masked) command line out of the + // buffers, so what we assert on can only have come from the child. + do! + ctx.RunSensitiveCommand( + $"sh -c \"echo out95-{secret}-end95; echo err95-{secret}-end95 >&2\"", + disablePrintCommand = true + ) + |> Async.Ignore + call () + }) + } + stage "" { + whenWindows + run (fun ctx -> async { + do! + ctx.RunSensitiveCommand( + $"powershell -Command \"echo out95-{secret}-end95; [Console]::Error.WriteLine('err95-{secret}-end95')\"", + disablePrintCommand = true + ) + |> Async.Ignore + call () + }) + } + runImmediate }) - } - runImmediate - } + stdout <- out + stderr <- err + ) + + Assert.DoesNotContain(secret, stdout) + Assert.DoesNotContain(secret, stderr) + Assert.Contains("out95-*-end95", stdout) + Assert.Contains("err95-*-end95", stderr) + [] -let ``RunCommandCaptureAll should not fail the stage on a non zero exit code`` () = +let ``The child's stderr should be written to stderr and not to stdout`` () = + let marker = "stderr95routing" + let mutable stdout = "" + let mutable stderr = "" + shouldBeCalled (fun call -> - pipeline "" { - stage "" { - whenAny { - platformOSX - platformLinux + let out, err = + captureConsole (fun () -> pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureAll($"sh -c \"echo {marker} >&2\"", disablePrintCommand = true) + Assert.Equal(0, result.ExitCode) + call () + }) } - run (fun ctx -> async { - let! result = ctx.RunCommandCaptureAll "sh -c \"exit 1\"" - Assert.Equal(1, result.ExitCode) - call () - }) - } - stage "" { - whenWindows - run (fun ctx -> async { - let! result = ctx.RunCommandCaptureAll "powershell -Command \"exit 1\"" - Assert.Equal(1, result.ExitCode) - call () - }) - } - runImmediate - } + stage "" { + whenWindows + run (fun ctx -> async { + let! result = + ctx.RunCommandCaptureAll($"powershell -Command \"[Console]::Error.WriteLine('{marker}')\"", disablePrintCommand = true) + Assert.Equal(0, result.ExitCode) + call () + }) + } + runImmediate + }) + stdout <- out + stderr <- err ) + Assert.Contains(marker, stderr) + Assert.DoesNotContain(marker, stdout) + + [] -let ``RunSensitiveCommandCaptureAll should work`` () = - pipeline "" { +let ``A cancelled command should be distinguishable from a failed one`` () = + shouldBeCalled (fun call -> pipeline "" { stage "" { whenAny { platformOSX platformLinux } run (fun ctx -> async { - let! result = ctx.RunSensitiveCommandCaptureAll $"""echo {"42"}""" - Assert.Equal(0, result.ExitCode) - Assert.Equal("42\n", result.StandardOutput) - Assert.Equal("", result.StandardError) + use cts = new CancellationTokenSource(500) + let! result = ctx.RunCommandCaptureAll("sh -c \"sleep 30\"", cancellationToken = cts.Token) + // Without IsCancelled this is just exit code 143, the same shape as a real failure. + Assert.True(result.IsCancelled) + call () }) } stage "" { whenWindows run (fun ctx -> async { - let! result = ctx.RunSensitiveCommandCaptureAll $"""powershell echo {"42"}""" - Assert.Equal(0, result.ExitCode) - Assert.Equal("42\r\n", result.StandardOutput) - Assert.Equal("", result.StandardError) + use cts = new CancellationTokenSource(500) + let! result = ctx.RunCommandCaptureAll("powershell -Command \"Start-Sleep -Seconds 30\"", cancellationToken = cts.Token) + Assert.True(result.IsCancelled) + call () }) } runImmediate - } + }) + + +[] +let ``Every line of the child's output should still get the step prefix`` () = + // RunCommand no longer builds its own preamble, so pin the prefixing it used to own. line96b is + // written without a trailing newline, which exercises the last line of the stream. + let mutable stdout = "" + + shouldBeCalled (fun call -> + let out, _ = + captureConsole (fun () -> pipeline "" { + noPrefixForStep false + stage "prefixed" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + do! ctx.RunCommand("sh -c \"echo line96a; printf line96b\"", disablePrintCommand = true) |> Async.Ignore + call () + }) + } + stage "prefixed" { + whenWindows + run (fun ctx -> async { + do! + ctx.RunCommand("powershell -Command \"echo line96a; [Console]::Out.Write('line96b')\"", disablePrintCommand = true) + |> Async.Ignore + call () + }) + } + runImmediate + }) + stdout <- out + ) + + Assert.Contains("prefixed line96a", stdout) + Assert.Contains("prefixed line96b", stdout) [] @@ -254,102 +416,90 @@ let ``Soft cancel should work`` () = let mutable i = 0 let mutable j = 0 - shouldBeCalled (fun call -> - pipeline "" { - timeout 2 - stage "" { - paralle - run (fun _ -> async { - while true do - do! Async.Sleep 100 - }) - run (fun ctx -> async { - while true do - do! Async.Sleep 100 - j <- j + 1 - printfn $"task2 {i}" - if i > 3 then ctx.SoftCancelStep() - }) - run (fun ctx -> async { - while true do - do! Async.Sleep 100 - i <- i + 1 - printfn $"task1 {i}" - if i > 5 then ctx.SoftCancelStage() - }) - } - stage "" { run call } - runImmediate + shouldBeCalled (fun call -> pipeline "" { + timeout 2 + stage "" { + paralle + run (fun _ -> async { + while true do + do! Async.Sleep 100 + }) + run (fun ctx -> async { + while true do + do! Async.Sleep 100 + j <- j + 1 + printfn $"task2 {i}" + if i > 3 then ctx.SoftCancelStep() + }) + run (fun ctx -> async { + while true do + do! Async.Sleep 100 + i <- i + 1 + printfn $"task1 {i}" + if i > 5 then ctx.SoftCancelStage() + }) } - ) + stage "" { run call } + runImmediate + }) Assert.True(10 <= i + j) [] let ``FailIfNoActiveSubStage`` () = - Assert.Throws(fun _ -> - pipeline "" { - stage "" { - failIfNoActiveSubStage - stage "" { when' false } - } - runImmediate + Assert.Throws(fun _ -> pipeline "" { + stage "" { + failIfNoActiveSubStage + stage "" { when' false } } - ) + runImmediate + }) |> ignore - Assert.Throws(fun _ -> - pipeline "" { - stage "" { - failIfNoActiveSubStage - echo "" - } - runImmediate + Assert.Throws(fun _ -> pipeline "" { + stage "" { + failIfNoActiveSubStage + echo "" } - ) + runImmediate + }) |> ignore - shouldBeCalled (fun call -> - pipeline "" { + shouldBeCalled (fun call -> pipeline "" { + stage "" { + failIfNoActiveSubStage + stage "" { when' false } stage "" { - failIfNoActiveSubStage - stage "" { when' false } - stage "" { - when' true - run (ignore >> call) - } + when' true + run (ignore >> call) } - runImmediate } - ) + runImmediate + }) - shouldBeCalled (fun call -> - pipeline "" { + shouldBeCalled (fun call -> pipeline "" { + stage "" { + failIfNoActiveSubStage + echo "" stage "" { - failIfNoActiveSubStage - echo "" - stage "" { - when' true - run (ignore >> call) - } + when' true + run (ignore >> call) } - runImmediate } - ) + runImmediate + }) [] let ``runHttpHealthCheck should work`` () = - shouldBeCalled (fun fn -> - pipeline "" { - stage "" { - runHttpHealthCheck "https://www.bing.com/" - run fn - } - runImmediate + shouldBeCalled (fun fn -> pipeline "" { + stage "" { + runHttpHealthCheck "https://www.bing.com/" + run fn } - ) + runImmediate + }) Assert.Throws(fun _ -> use cts = new System.Threading.CancellationTokenSource(3000) diff --git a/Fun.Build.Tests/TimeoutTests.fs b/Fun.Build.Tests/TimeoutTests.fs index ce255d7..5104da8 100644 --- a/Fun.Build.Tests/TimeoutTests.fs +++ b/Fun.Build.Tests/TimeoutTests.fs @@ -6,102 +6,88 @@ open Fun.Build [] let ``timeout should work`` () = - shouldBeCalled (fun call -> - pipeline "timeout" { + shouldBeCalled (fun call -> pipeline "timeout" { + timeout 1 + stage "timeout" { + run ( + async { + do! Async.Sleep 100 + call () + } + ) + } + runImmediate + }) + + Assert.Throws(fun _ -> + shouldNotBeCalled (fun call -> pipeline "timeout" { timeout 1 stage "timeout" { run ( async { - do! Async.Sleep 100 + do! Async.Sleep 2000 call () } ) } runImmediate - } - ) - - Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "timeout" { - timeout 1 - stage "timeout" { - run ( - async { - do! Async.Sleep 2000 - call () - } - ) - } - runImmediate - } - ) + }) ) |> ignore - Assert.Throws(fun _ -> - pipeline "timeout" { - timeout 2 - stage "timeout1" { run (Async.Sleep 1000) } - stage "timeout2" { run (Async.Sleep 1100) } - runImmediate - } - ) + Assert.Throws(fun _ -> pipeline "timeout" { + timeout 2 + stage "timeout1" { run (Async.Sleep 1000) } + stage "timeout2" { run (Async.Sleep 1100) } + runImmediate + }) [] let ``timeoutForStage should work`` () = - Assert.Throws(fun _ -> - pipeline "timeoutForStage" { - timeoutForStage 1 - stage "timeoutForStage" { run (Async.Sleep 2100) } - runImmediate - } - ) + Assert.Throws(fun _ -> pipeline "timeoutForStage" { + timeoutForStage 1 + stage "timeoutForStage" { run (Async.Sleep 2100) } + runImmediate + }) |> ignore - Assert.Throws(fun _ -> - pipeline "timeoutForStage" { - timeoutForStage 2 - stage "timeoutForStage" { - timeout 1 - run (Async.Sleep 1100) - } - runImmediate + Assert.Throws(fun _ -> pipeline "timeoutForStage" { + timeoutForStage 2 + stage "timeoutForStage" { + timeout 1 + run (Async.Sleep 1100) } - ) + runImmediate + }) [] let ``timeoutForStep should work`` () = Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "timeoutForStep" { - timeoutForStep 1 - stage "timeoutForStep" { - run (Async.Sleep 500) - run (Async.Sleep 1100) - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "timeoutForStep" { + timeoutForStep 1 + stage "timeoutForStep" { + run (Async.Sleep 500) + run (Async.Sleep 1100) + run call } - ) + runImmediate + }) ) |> ignore Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "timeoutForStep" { - timeoutForStep 2 - stage "timeoutForStep" { - timeoutForStep 1 - run (Async.Sleep 500) - run (Async.Sleep 1100) - run call - } - runImmediate + shouldNotBeCalled (fun call -> pipeline "timeoutForStep" { + timeoutForStep 2 + stage "timeoutForStep" { + timeoutForStep 1 + run (Async.Sleep 500) + run (Async.Sleep 1100) + run call } - ) + runImmediate + }) ) @@ -109,94 +95,82 @@ let ``timeoutForStep should work`` () = [] let ``nested stage timeout should work`` () = Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "timeout" { - timeout 1 + shouldNotBeCalled (fun call -> pipeline "timeout" { + timeout 1 + stage "" { stage "" { - stage "" { - run (Async.Sleep 500) - run (Async.Sleep 1100) - run call - } + run (Async.Sleep 500) + run (Async.Sleep 1100) + run call } - runImmediate } - ) + runImmediate + }) ) |> ignore Assert.Throws(fun _ -> - shouldNotBeCalled (fun call -> - pipeline "timeout" { - timeout 1 + shouldNotBeCalled (fun call -> pipeline "timeout" { + timeout 1 + stage "" { + timeout 2 stage "" { - timeout 2 - stage "" { - run (Async.Sleep 500) - run (Async.Sleep 1100) - run call - } + run (Async.Sleep 500) + run (Async.Sleep 1100) + run call } - runImmediate } - ) + runImmediate + }) ) |> ignore [] let ``nested stage timeoutForStep should work`` () = - Assert.Throws(fun _ -> - pipeline "timeoutForStep" { - timeoutForStep 1 - stage "" { stage "" { run (Async.Sleep 1100) } } - runImmediate - } - ) + Assert.Throws(fun _ -> pipeline "timeoutForStep" { + timeoutForStep 1 + stage "" { stage "" { run (Async.Sleep 1100) } } + runImmediate + }) |> ignore Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "timeoutForStep" { - timeoutForStep 1 + shouldBeCalled (fun call -> pipeline "timeoutForStep" { + timeoutForStep 1 + stage "" { stage "" { - stage "" { - timeoutForStep 2 - run (Async.Sleep 1100) - run call - } + timeoutForStep 2 + run (Async.Sleep 1100) + run call } - runImmediate } - ) + runImmediate + }) ) |> ignore [] let ``nested stage timeoutForStage should work`` () = - Assert.Throws(fun _ -> - pipeline "timeoutForStage" { - timeoutForStage 1 - stage "" { stage "" { run (Async.Sleep 1100) } } - runImmediate - } - ) + Assert.Throws(fun _ -> pipeline "timeoutForStage" { + timeoutForStage 1 + stage "" { stage "" { run (Async.Sleep 1100) } } + runImmediate + }) |> ignore Assert.Throws(fun _ -> - shouldBeCalled (fun call -> - pipeline "timeoutForStage" { - timeoutForStage 1 + shouldBeCalled (fun call -> pipeline "timeoutForStage" { + timeoutForStage 1 + stage "" { stage "" { - stage "" { - timeout 2 - run (Async.Sleep 1100) - run call - } + timeout 2 + run (Async.Sleep 1100) + run call } - runImmediate } - ) + runImmediate + }) ) |> ignore diff --git a/Fun.Build.Tests/Utils.fs b/Fun.Build.Tests/Utils.fs index af6b0fc..baff4c8 100644 --- a/Fun.Build.Tests/Utils.fs +++ b/Fun.Build.Tests/Utils.fs @@ -1,6 +1,9 @@ [] module Fun.Build.Tests.Utils +open System +open System.IO +open Spectre.Console open Xunit @@ -18,3 +21,31 @@ let shouldNotBeCalled fn = fn (fun _ -> isCalled <- true) Assert.False isCalled + + +/// Run fn with this process' stdout and stderr redirected, and hand back what each one received. +/// xUnit runs collections in parallel, so output from other tests can land in these buffers too. +/// Assert on a marker unique to your own command rather than on the buffer as a whole. +let captureConsole (fn: unit -> unit) = + // Deliberately not disposed. Spectre hands the writer to a cached console and goes on holding it, + // so disposing here would make every later test die on a closed TextWriter. + let out = new StringWriter() + let err = new StringWriter() + let syncOut = TextWriter.Synchronized out + let originalOut = Console.Out + let originalErr = Console.Error + let originalAnsiConsole = AnsiConsole.Console + + try + Console.SetOut syncOut + Console.SetError(TextWriter.Synchronized err) + // Redirecting Console.Out is not enough on its own: AnsiConsole was built around whatever + // Console.Out was the first time it was touched, and keeps writing there. + AnsiConsole.Console <- AnsiConsole.Create(AnsiConsoleSettings(Out = AnsiConsoleOutput(syncOut))) + fn () + finally + AnsiConsole.Console <- originalAnsiConsole + Console.SetOut originalOut + Console.SetError originalErr + + out.ToString(), err.ToString() diff --git a/Fun.Build/BuiltinCmds.fs b/Fun.Build/BuiltinCmds.fs index a7c4913..15502f9 100644 --- a/Fun.Build/BuiltinCmds.fs +++ b/Fun.Build/BuiltinCmds.fs @@ -12,6 +12,27 @@ open Fun.Build.StageContextExtensionsInternal module BuiltinCmdsInternal = + /// The interpolated argument values, so they can be replaced in the child's own output too. + /// Masking the logged command alone leaves the log looking safe while the child echoes the + /// secret straight back on stdout or stderr. + let maskedCommandValues (commandStr: FormattableString) = + commandStr.GetArguments() |> Array.map string |> Array.filter (String.IsNullOrEmpty >> not) + + /// The command as it should be logged: every interpolated argument replaced by a *. + let maskedCommandString (commandStr: FormattableString) = + let args: obj[] = Array.create commandStr.ArgumentCount "*" + String.Format(commandStr.Format, args) + + /// Turn a finished command into the result the CaptureOutput members hand back. A cancelled + /// command counts as successful: StageBuilder documents the token as a way to stop a command and + /// still mark it as success. + let toCapturedOutputResult (ctx: StageContext) (ct: CancellationToken) (result: CommandOutput) = + if ct.IsCancellationRequested || ctx.IsAcceptableExitCode result.ExitCode then + Ok result.StandardOutput + else + Error "Exit code is not indicating as successful." + + type StageContext with /// Build a ProcessStartInfo object for a command string. If your command is a file path with white space, you should quote it with ' or ". @@ -49,41 +70,44 @@ module BuiltinCmdsInternal = ?workingDir: string, ?disablePrintOutput: bool, ?disablePrintCommand: bool, - ?cancellationToken: CancellationToken - ) : Async = - async { - let disablePrintOutput = defaultArg disablePrintOutput false - let disablePrintCommand = defaultArg disablePrintCommand false - let command = ctx.BuildCommand(commandStr, ?workingDir = workingDir) - let noPrefixForStep = ctx.GetNoPrefixForStep() - let prefix = - if noPrefixForStep then - "" - else - match step with - | Some i -> ctx.BuildStepPrefix i - | None -> ctx.GetNamePath() - - if not noPrefixForStep then AnsiConsole.Markup $"[green]{prefix}[/] " - if not disablePrintCommand then AnsiConsole.WriteLine commandLogString - - let ct = defaultArg cancellationToken CancellationToken.None - - return! - Process.StartAsync( - command, - commandLogString, - prefix, - printOutput = (not disablePrintOutput && not (ctx.GetNoStdRedirectForStep())), - captureOutput = true, - cancellationToken = ct - ) - } + ?cancellationToken: CancellationToken, + ?maskedValues: string seq, + ?captureOutput: bool + ) : Async = async { + let disablePrintOutput = defaultArg disablePrintOutput false + let disablePrintCommand = defaultArg disablePrintCommand false + let captureOutput = defaultArg captureOutput true + let command = ctx.BuildCommand(commandStr, ?workingDir = workingDir) + let noPrefixForStep = ctx.GetNoPrefixForStep() + let prefix = + if noPrefixForStep then + "" + else + match step with + | Some i -> ctx.BuildStepPrefix i + | None -> ctx.GetNamePath() + + if not noPrefixForStep then AnsiConsole.Markup $"[green]{prefix}[/] " + if not disablePrintCommand then AnsiConsole.WriteLine commandLogString + + let ct = defaultArg cancellationToken CancellationToken.None + + return! + Process.StartAsync( + command, + commandLogString, + prefix, + printOutput = (not disablePrintOutput && not (ctx.GetNoStdRedirectForStep())), + captureOutput = captureOutput, + cancellationToken = ct, + ?maskedValues = maskedValues + ) + } /// Add command to context - member ctx.AddCommandStep(commandStrFn: StageContext -> Async, ?cancellationToken: CancellationToken) = - { ctx with + member ctx.AddCommandStep(commandStrFn: StageContext -> Async, ?cancellationToken: CancellationToken) = { + ctx with Steps = ctx.Steps @ [ @@ -92,7 +116,7 @@ module BuiltinCmdsInternal = return! ctx.RunCommand(commandStr, i, cancellationToken = defaultArg cancellationToken CancellationToken.None) }) ] - } + } [] @@ -103,7 +127,12 @@ module BuiltinCmds = type StageContext with - /// Run a command string with current context + /// + /// Run a command string with current context. + /// + /// Command to run + /// Current step rank + /// Working directory for command member ctx.RunCommand ( commandStr: string, @@ -112,40 +141,30 @@ module BuiltinCmds = ?disablePrintOutput: bool, ?disablePrintCommand: bool, ?cancellationToken: CancellationToken - ) = - async { - let disablePrintOutput = defaultArg disablePrintOutput false - let disablePrintCommand = defaultArg disablePrintCommand false - let command = ctx.BuildCommand(commandStr, ?workingDir = workingDir) - let noPrefixForStep = ctx.GetNoPrefixForStep() - let prefix = - if noPrefixForStep then - "" - else - match step with - | Some i -> ctx.BuildStepPrefix i - | None -> ctx.GetNamePath() - - if not noPrefixForStep then AnsiConsole.Markup $"[green]{prefix}[/] " - if not disablePrintCommand then AnsiConsole.WriteLine commandStr - - let ct = defaultArg cancellationToken CancellationToken.None - - let! result = - Process.StartAsync( - command, - commandStr, - prefix, - printOutput = (not disablePrintOutput && not (ctx.GetNoStdRedirectForStep())), - cancellationToken = ct - ) - - return - if ct.IsCancellationRequested then - Ok() - else - ctx.MapExitCodeToResult result.ExitCode - } + ) : Async> = async { + let ct = defaultArg cancellationToken CancellationToken.None + + // Nothing is captured here, and that is deliberate: StartAsync only redirects when it has + // to, and redirecting loses the child's colours. Capturing would quietly turn every plain + // `run "..."` monochrome. + let! result = + ctx.RunCommandCaptureAllInternal( + commandStr, + commandStr, + ?step = step, + ?workingDir = workingDir, + ?disablePrintOutput = disablePrintOutput, + ?disablePrintCommand = disablePrintCommand, + cancellationToken = ct, + captureOutput = false + ) + + return + if ct.IsCancellationRequested then + Ok() + else + ctx.MapExitCodeToResult result.ExitCode + } /// /// Run a command string with current context, and return the standard output if the exit code is acceptable. @@ -161,45 +180,27 @@ module BuiltinCmds = ?disablePrintOutput: bool, ?disablePrintCommand: bool, ?cancellationToken: CancellationToken - ) = - async { - let disablePrintOutput = defaultArg disablePrintOutput false - let disablePrintCommand = defaultArg disablePrintCommand false - let command = ctx.BuildCommand(commandStr, ?workingDir = workingDir) - let noPrefixForStep = ctx.GetNoPrefixForStep() - let prefix = - if noPrefixForStep then - "" - else - match step with - | Some i -> ctx.BuildStepPrefix i - | None -> ctx.GetNamePath() - - if not noPrefixForStep then AnsiConsole.Markup $"[green]{prefix}[/] " - if not disablePrintCommand then AnsiConsole.WriteLine commandStr - - let ct = defaultArg cancellationToken CancellationToken.None - - let! result = - Process.StartAsync( - command, - commandStr, - prefix, - printOutput = (not disablePrintOutput && not (ctx.GetNoStdRedirectForStep())), - captureOutput = true, - cancellationToken = ct - ) - - if ct.IsCancellationRequested then - return Ok result.StandardOutput - else if ctx.IsAcceptableExitCode result.ExitCode then - return Ok result.StandardOutput - else - return Error "Exit code is not indicating as successful." - } + ) : Async> = async { + let ct = defaultArg cancellationToken CancellationToken.None + + let! result = + ctx.RunCommandCaptureAllInternal( + commandStr, + commandStr, + ?step = step, + ?workingDir = workingDir, + ?disablePrintOutput = disablePrintOutput, + ?disablePrintCommand = disablePrintCommand, + cancellationToken = ct + ) + + return toCapturedOutputResult ctx ct result + } /// Run a command string with current context, and encrypt the string for logging + /// The interpolated arguments are also replaced with * in the child's own standard output and standard error. + /// This is a plain substring replacement, so a short value is replaced wherever it appears in that output. member ctx.RunSensitiveCommandCaptureOutput ( commandStr: FormattableString, @@ -208,45 +209,24 @@ module BuiltinCmds = ?disablePrintOutput: bool, ?disablePrintCommand: bool, ?cancellationToken: CancellationToken - ) : Async> = - async { - let disablePrintOutput = defaultArg disablePrintOutput false - let disablePrintCommand = defaultArg disablePrintCommand false - let command = ctx.BuildCommand(commandStr.ToString(), ?workingDir = workingDir) - let noPrefixForStep = ctx.GetNoPrefixForStep() - let args: obj[] = Array.create commandStr.ArgumentCount "*" - let encryptiedStr = String.Format(commandStr.Format, args) - - let prefix = - if noPrefixForStep then - "" - else - match step with - | Some i -> ctx.BuildStepPrefix i - | None -> ctx.GetNamePath() - - if not noPrefixForStep then AnsiConsole.Markup $"[green]{prefix}[/] " - if not disablePrintCommand then AnsiConsole.WriteLine encryptiedStr - - let ct = defaultArg cancellationToken CancellationToken.None - - let! result = - Process.StartAsync( - command, - encryptiedStr, - prefix, - printOutput = (not disablePrintOutput && not (ctx.GetNoStdRedirectForStep())), - captureOutput = true, - cancellationToken = ct - ) + ) : Async> = async { + let ct = defaultArg cancellationToken CancellationToken.None + + let! result = + ctx.RunCommandCaptureAllInternal( + commandStr.ToString(), + maskedCommandString commandStr, + ?step = step, + ?workingDir = workingDir, + ?disablePrintOutput = disablePrintOutput, + ?disablePrintCommand = disablePrintCommand, + cancellationToken = ct, + maskedValues = maskedCommandValues commandStr + ) + + return toCapturedOutputResult ctx ct result + } - if ct.IsCancellationRequested then - return Ok result.StandardOutput - else if ctx.IsAcceptableExitCode result.ExitCode then - return Ok result.StandardOutput - else - return Error "Exit code is not indicating as successful." - } /// /// Run a command string with current context, and return the exit code, standard output and standard error @@ -276,6 +256,8 @@ module BuiltinCmds = /// Same as RunCommandCaptureAll, but encrypt the string for logging + /// The interpolated arguments are also replaced with * in the child's own standard output and standard error. + /// This is a plain substring replacement, so a short value is replaced wherever it appears in that output. member ctx.RunSensitiveCommandCaptureAll ( commandStr: FormattableString, @@ -285,20 +267,21 @@ module BuiltinCmds = ?disablePrintCommand: bool, ?cancellationToken: CancellationToken ) : Async = - let args: obj[] = Array.create commandStr.ArgumentCount "*" - let encryptiedStr = String.Format(commandStr.Format, args) ctx.RunCommandCaptureAllInternal( commandStr.ToString(), - encryptiedStr, + maskedCommandString commandStr, ?step = step, ?workingDir = workingDir, ?disablePrintOutput = disablePrintOutput, ?disablePrintCommand = disablePrintCommand, - ?cancellationToken = cancellationToken + ?cancellationToken = cancellationToken, + maskedValues = maskedCommandValues commandStr ) /// Run a command string with current context, and encrypt the string for logging + /// The interpolated arguments are also replaced with * in the child's own standard output and standard error. + /// This is a plain substring replacement, so a short value is replaced wherever it appears in that output. member ctx.RunSensitiveCommand ( commandStr: FormattableString, diff --git a/Fun.Build/CHANGELOG.md b/Fun.Build/CHANGELOG.md index d3df5c5..69a2e5f 100644 --- a/Fun.Build/CHANGELOG.md +++ b/Fun.Build/CHANGELOG.md @@ -2,9 +2,12 @@ ## [Unreleased] -- Add `RunCommandCaptureAll` and `RunSensitiveCommandCaptureAll` which return a `CommandOutput` record with the exit code, standard output and standard error, whatever the exit code was #93 -- Fix: standard error was never read when output is redirected (prefixed, silenced or captured), so it was lost. It is now printed like standard output. +- Add `RunCommandCaptureAll` and `RunSensitiveCommandCaptureAll`, which return a `CommandOutput` record with the exit code, standard output, standard error and whether your cancellation token stopped the run, whatever the exit code was #93 #96 +- Breaking: `Process.StartAsync` now returns the `CommandOutput` record instead of the anonymous struct it returned in 1.1.18. Source and binary breaking if you call it directly #93 +- Fix: standard error was never read when output is redirected (prefixed, silenced or captured), so it was lost. It is now read, and written to standard error #95 - Fix: captured standard output no longer ends with an extra empty line +- Fix: `RunSensitiveCommand`, `RunSensitiveCommandCaptureOutput` and `RunSensitiveCommandCaptureAll` now mask the interpolated values in the child's own standard output and standard error, not only in the logged command #95 +- `IsAcceptableExitCode` and `MapExitCodeToResult` moved to the auto opened `StageContextExtensions`, so a caller holding a `CommandOutput` can honour `acceptExitCodes` #96 ## [1.1.18] - 2026-08-31 diff --git a/Fun.Build/Changelog.fs b/Fun.Build/Changelog.fs index 370cf35..90d9a1d 100644 --- a/Fun.Build/Changelog.fs +++ b/Fun.Build/Changelog.fs @@ -51,10 +51,9 @@ type Changelog = match version with | None -> None | Some v -> - Some - {| - Version = v - Preview = preview - ReleaseNotes = releaseNotes.ToString() - DateTime = dateTime - |} + Some {| + Version = v + Preview = preview + ReleaseNotes = releaseNotes.ToString() + DateTime = dateTime + |} diff --git a/Fun.Build/ConditionsBuilder.fs b/Fun.Build/ConditionsBuilder.fs index 2c21d8f..b37b024 100644 --- a/Fun.Build/ConditionsBuilder.fs +++ b/Fun.Build/ConditionsBuilder.fs @@ -35,10 +35,10 @@ module Internal = member ctx.WhenStage(stage: StageContext) = match ctx.GetMode() with | Mode.Execution -> - let stage = - { stage with + let stage = { + stage with ParentContext = ValueSome(StageParent.Stage ctx) - } + } let result, _ = stage.Run(StageIndex.Condition, System.Threading.CancellationToken.None) result | Mode.Verification -> @@ -79,15 +79,12 @@ module Internal = member ctx.WhenEnvArg(name: string, ?argValue, ?description, ?isOptional) = let argValue = defaultArg argValue "" - ctx.WhenEnvArg - { - EnvArg.Name = name - Description = description - Values = [ - if String.IsNullOrEmpty argValue |> not then argValue - ] - IsOptional = defaultArg isOptional false - } + ctx.WhenEnvArg { + EnvArg.Name = name + Description = description + Values = [ if String.IsNullOrEmpty argValue |> not then argValue ] + IsOptional = defaultArg isOptional false + } member ctx.WhenCmdArg(info: CmdArg) = @@ -124,15 +121,12 @@ module Internal = member ctx.WhenCmdArg(name: CmdName, ?argValue: string, ?description, ?isOptional) = let argValue = defaultArg argValue "" - ctx.WhenCmdArg - { - CmdArg.Name = name - Description = description - Values = [ - if String.IsNullOrEmpty argValue |> not then argValue - ] - IsOptional = defaultArg isOptional false - } + ctx.WhenCmdArg { + CmdArg.Name = name + Description = description + Values = [ if String.IsNullOrEmpty argValue |> not then argValue ] + IsOptional = defaultArg isOptional false + } member ctx.WhenBranch(branches: string seq) = @@ -267,13 +261,8 @@ type ConditionsBuilder() = [] member inline _.cmdArg - ( - [] builder: BuildConditions, - argKeyLongName: string, - argValue: string, - description: string, - isOptional: bool - ) = + ([] builder: BuildConditions, argKeyLongName: string, argValue: string, description: string, isOptional: bool) + = buildConditions builder (fun ctx -> ctx.WhenCmdArg(CmdName.LongName argKeyLongName, argValue = argValue, description = description, isOptional = isOptional)) @@ -444,13 +433,8 @@ type PipelineBuilder with /// Set if pipeline can run by check the command line args. [] member inline _.whenCmdArg - ( - [] build: BuildPipeline, - argKeyLongName: string, - argValue: string, - description: string, - isOptional: bool - ) = + ([] build: BuildPipeline, argKeyLongName: string, argValue: string, description: string, isOptional: bool) + = buildPipelineVerification build (fun ctx -> @@ -498,14 +482,14 @@ type WhenAnyBuilder() = | Mode.CommandHelp { Verbose = true } -> AnsiConsole.MarkupLine $"[olive]{ctx.BuildIndent()}when any below conditions are met[/]" | _ -> () - let indentCtx = - { StageContext.Create " " with + let indentCtx = { + StageContext.Create " " with ParentContext = ctx.ParentContext - } - let newCtx = - { ctx with + } + let newCtx = { + ctx with ParentContext = ValueSome(StageParent.Stage indentCtx) - } + } builder.Invoke [] |> Seq.iter (fun fn -> fn newCtx |> ignore) false ) @@ -524,14 +508,14 @@ type WhenAllBuilder() = | Mode.CommandHelp { Verbose = true } -> AnsiConsole.MarkupLine $"[olive]{ctx.BuildIndent()}when all below conditions are met[/]" | _ -> () - let indentCtx = - { StageContext.Create " " with + let indentCtx = { + StageContext.Create " " with ParentContext = ctx.ParentContext - } - let newCtx = - { ctx with + } + let newCtx = { + ctx with ParentContext = ValueSome(StageParent.Stage indentCtx) - } + } builder.Invoke [] |> Seq.iter (fun fn -> fn newCtx |> ignore) false ) @@ -551,14 +535,14 @@ type WhenNotBuilder() = AnsiConsole.MarkupLine $"[olive]{ctx.BuildIndent()}when all below conditions are [bold red]NOT[/] met[/]" | _ -> () - let indentCtx = - { StageContext.Create " " with + let indentCtx = { + StageContext.Create " " with ParentContext = ctx.ParentContext - } - let newCtx = - { ctx with + } + let newCtx = { + ctx with ParentContext = ValueSome(StageParent.Stage indentCtx) - } + } builder.Invoke [] |> Seq.iter (fun fn -> fn newCtx |> ignore) false ) @@ -569,14 +553,13 @@ type WhenCmdBuilder() = member _.Run(build: BuildCmdInfo) = BuildStageIsActive(fun ctx -> let cmdInfo = - build.Invoke - { - // We should carefully procees the empty string in this build type - Name = CmdName.ShortName "" - Description = None - Values = [] - IsOptional = false - } + build.Invoke { + // We should carefully procees the empty string in this build type + Name = CmdName.ShortName "" + Description = None + Values = [] + IsOptional = false + } ctx.WhenCmdArg(cmdInfo) ) @@ -588,11 +571,10 @@ type WhenCmdBuilder() = /// Short name, long name [] member inline _.fullName([] build: BuildCmdInfo, shortName: string, longName: string) = - BuildCmdInfo(fun info -> - { build.Invoke(info) with + BuildCmdInfo(fun info -> { + build.Invoke(info) with Name = CmdName.FullName(shortName, longName) - } - ) + }) /// It is the same as shortName [] @@ -607,12 +589,13 @@ type WhenCmdBuilder() = member _.shortName(build: BuildCmdInfo, x: string) = BuildCmdInfo(fun info -> let info = build.Invoke(info) - { info with - Name = - match info.Name with - | CmdName.FullName(_, longName) - | CmdName.LongName longName when not (String.IsNullOrEmpty longName) -> CmdName.FullName(x, longName) - | _ -> CmdName.ShortName x + { + info with + Name = + match info.Name with + | CmdName.FullName(_, longName) + | CmdName.LongName longName when not (String.IsNullOrEmpty longName) -> CmdName.FullName(x, longName) + | _ -> CmdName.ShortName x } ) @@ -620,12 +603,13 @@ type WhenCmdBuilder() = member _.longName(build: BuildCmdInfo, x: string) = BuildCmdInfo(fun info -> let info = build.Invoke(info) - { info with - Name = - match info.Name with - | CmdName.FullName(shortName, _) - | CmdName.ShortName shortName when not (String.IsNullOrEmpty shortName) -> CmdName.FullName(shortName, x) - | _ -> CmdName.LongName x + { + info with + Name = + match info.Name with + | CmdName.FullName(shortName, _) + | CmdName.ShortName shortName when not (String.IsNullOrEmpty shortName) -> CmdName.FullName(shortName, x) + | _ -> CmdName.LongName x } ) @@ -650,14 +634,13 @@ type WhenEnvBuilder() = member _.Run(build: BuildEnvInfo) = BuildStageIsActive(fun ctx -> let arg = - build.Invoke - { - // We should carefully procees the empty string in this build type - Name = "" - Description = None - Values = [] - IsOptional = false - } + build.Invoke { + // We should carefully procees the empty string in this build type + Name = "" + Description = None + Values = [] + IsOptional = false + } ctx.WhenEnvArg(arg) ) diff --git a/Fun.Build/PipelineBuilder.fs b/Fun.Build/PipelineBuilder.fs index 6627b80..cc427c5 100644 --- a/Fun.Build/PipelineBuilder.fs +++ b/Fun.Build/PipelineBuilder.fs @@ -21,21 +21,20 @@ type PipelineBuilder(name: string) = member _.Run(build: BuildPipeline) = let ctx = build.Invoke(PipelineContext.Create name) - { ctx with - Stages = - ctx.Stages - |> List.map (fun x -> - { x with - ParentContext = ValueSome(StageParent.Pipeline ctx) - } - ) - PostStages = - ctx.PostStages - |> List.map (fun x -> - { x with - ParentContext = ValueSome(StageParent.Pipeline ctx) - } - ) + { + ctx with + Stages = + ctx.Stages + |> List.map (fun x -> { + x with + ParentContext = ValueSome(StageParent.Pipeline ctx) + }) + PostStages = + ctx.PostStages + |> List.map (fun x -> { + x with + ParentContext = ValueSome(StageParent.Pipeline ctx) + }) } @@ -70,11 +69,10 @@ type PipelineBuilder(name: string) = member inline _.Yield([] condition: BuildStageIsActive) = condition member inline _.Delay([] fn: unit -> BuildStageIsActive) = - BuildPipeline(fun ctx -> - { ctx with + BuildPipeline(fun ctx -> { + ctx with Verify = fun ctx -> fn().Invoke(ctx.MakeVerificationStage()) - } - ) + }) member inline _.Combine([] condition: BuildStageIsActive, [] build: BuildPipeline) = buildPipelineVerification build condition.Invoke @@ -104,8 +102,9 @@ type PipelineBuilder(name: string) = member inline _.timeout([] build: BuildPipeline, seconds: int) = BuildPipeline(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Timeout = ValueSome(TimeSpan.FromSeconds seconds) + { + ctx with + Timeout = ValueSome(TimeSpan.FromSeconds seconds) } ) @@ -123,8 +122,9 @@ type PipelineBuilder(name: string) = member inline _.timeoutForStage([] build: BuildPipeline, seconds: int) = BuildPipeline(fun ctx -> let ctx = build.Invoke ctx - { ctx with - TimeoutForStage = ValueSome(TimeSpan.FromSeconds seconds) + { + ctx with + TimeoutForStage = ValueSome(TimeSpan.FromSeconds seconds) } ) @@ -142,8 +142,9 @@ type PipelineBuilder(name: string) = member inline _.timeoutForStep([] build: BuildPipeline, seconds: int) = BuildPipeline(fun ctx -> let ctx = build.Invoke ctx - { ctx with - TimeoutForStep = ValueSome(TimeSpan.FromSeconds seconds) + { + ctx with + TimeoutForStep = ValueSome(TimeSpan.FromSeconds seconds) } ) @@ -161,8 +162,9 @@ type PipelineBuilder(name: string) = member inline _.envVars([] build: BuildPipeline, kvs: seq) = BuildPipeline(fun ctx -> let ctx = build.Invoke ctx - { ctx with - EnvVars = kvs |> Seq.fold (fun state (k, v) -> Map.add k v state) ctx.EnvVars + { + ctx with + EnvVars = kvs |> Seq.fold (fun state (k, v) -> Map.add k v state) ctx.EnvVars } ) @@ -183,9 +185,10 @@ type PipelineBuilder(name: string) = let argsInfo = resolveCmdArgsAndRemainings args - { ctx with - CmdArgs = argsInfo.CmdArgs - RemainingCmdArgs = argsInfo.RemainingArgs + { + ctx with + CmdArgs = argsInfo.CmdArgs + RemainingCmdArgs = argsInfo.RemainingArgs } ) @@ -278,11 +281,11 @@ type PipelineBuilder(name: string) = else args[index .. pipelineIndexes[i + 1] - 1] let argInfo = resolveCmdArgsAndRemainings args - let ctx = - { ctx with + let ctx = { + ctx with CmdArgs = argInfo.CmdArgs RemainingCmdArgs = argInfo.RemainingArgs - } + } if isHelp then ctx.RunCommandHelp(verbose) else ctx.Run() with | :? PipelineFailedException diff --git a/Fun.Build/PipelineContextExtensions.fs b/Fun.Build/PipelineContextExtensions.fs index 98141af..7c07bfd 100644 --- a/Fun.Build/PipelineContextExtensions.fs +++ b/Fun.Build/PipelineContextExtensions.fs @@ -56,10 +56,10 @@ module PipelineContextExtensionsInternal = | _ -> ValueNone - member this.MakeVerificationStage() = - { StageContext.Create("") with + member this.MakeVerificationStage() = { + StageContext.Create("") with ParentContext = ValueSome(StageParent.Pipeline this) - } + } member this.PrintError(msg: string) = @@ -75,11 +75,10 @@ module PipelineContextExtensionsInternal = let stages = stages - |> Seq.map (fun x -> - { x with + |> Seq.map (fun x -> { + x with ParentContext = ValueSome(StageParent.Pipeline this) - } - ) + }) |> Seq.toList let mutable i = 0 @@ -239,25 +238,25 @@ module PipelineContextExtensionsInternal = match step with | Step.StepFn _ -> () | Step.StepOfStage s -> - run - { s with + run { + s with ParentContext = ValueSome(StageParent.Stage stage) - } + } pipeline.Stages |> List.iter (fun stage -> - run - { stage with + run { + stage with ParentContext = ValueSome(StageParent.Pipeline pipeline) - } + } ) pipeline.PostStages |> List.iter (fun stage -> - run - { stage with + run { + stage with ParentContext = ValueSome(StageParent.Pipeline pipeline) - } + } ) if not verbose then @@ -299,15 +298,16 @@ module PipelineContextExtensionsInternal = let inline buildPipelineVerification ([] build: BuildPipeline) ([] conditionFn) = BuildPipeline(fun ctx -> let newCtx = build.Invoke ctx - { newCtx with - Verify = - fun ctx -> - match ctx.Mode with - | Mode.Execution -> newCtx.Verify ctx && conditionFn (ctx.MakeVerificationStage()) - | Mode.Verification - | Mode.CommandHelp _ -> - newCtx.Verify ctx |> ignore - conditionFn (ctx.MakeVerificationStage()) |> ignore - false + { + newCtx with + Verify = + fun ctx -> + match ctx.Mode with + | Mode.Execution -> newCtx.Verify ctx && conditionFn (ctx.MakeVerificationStage()) + | Mode.Verification + | Mode.CommandHelp _ -> + newCtx.Verify ctx |> ignore + conditionFn (ctx.MakeVerificationStage()) |> ignore + false } ) diff --git a/Fun.Build/ProcessExtensions.fs b/Fun.Build/ProcessExtensions.fs index e2b4b9e..f9764e2 100644 --- a/Fun.Build/ProcessExtensions.fs +++ b/Fun.Build/ProcessExtensions.fs @@ -107,76 +107,96 @@ type Process with logPrefix: string, ?printOutput, ?captureOutput, - ?cancellationToken: CancellationToken - ) = - async { - let printOutput = defaultArg printOutput true - let captureOutput = defaultArg captureOutput false - let noPrefix = String.IsNullOrEmpty logPrefix - // We want to redirect output if - // 1. Use want to add prefix to the output - // 2. User asked to not print output - // 3. User asked to capture output - let shouldRedirectOutput = not noPrefix || not printOutput || captureOutput - - // By default, we don't redirect output because redirecting the - // output lose the color information. - if shouldRedirectOutput then - // We redirect both standard output and error output - // because some process write mixed output to both... - // This should in theory avoid losing information because of the redirection. - startInfo.RedirectStandardOutput <- true - startInfo.RedirectStandardError <- true - startInfo.StandardOutputEncoding <- Encoding.UTF8 - startInfo.StandardErrorEncoding <- Encoding.UTF8 - - use result = Process.Start startInfo - let standardOutputSb = StringBuilder() - let standardErrorSb = StringBuilder() - - let handleDataReceived (sb: StringBuilder) (ev: DataReceivedEventArgs) = - // Data is null once the stream is closed - if ev.Data <> null then - if captureOutput then sb.AppendLine ev.Data |> ignore - if printOutput && not (String.IsNullOrEmpty ev.Data) then - if noPrefix then - Console.WriteLine(ev.Data) - else - Console.WriteLine(logPrefix + " " + ev.Data) - - if shouldRedirectOutput then - result.OutputDataReceived.Add(handleDataReceived standardOutputSb) - result.ErrorDataReceived.Add(handleDataReceived standardErrorSb) - - use! cd = - Async.OnCancel(fun _ -> - AnsiConsole.Markup $"[yellow]{logPrefix}[/] " - AnsiConsole.WriteLine $"{commandLogString}: is cancelled or timed out and the process will be killed." + ?cancellationToken: CancellationToken, + ?maskedValues: string seq + ) = async { + let printOutput = defaultArg printOutput true + let captureOutput = defaultArg captureOutput false + // Values the caller considers secret. They are masked on the way to the console only: + // captured output is handed back verbatim, because the caller asked for it and already knows them. + let maskedValues = + match maskedValues with + | None -> Array.empty + | Some values -> values |> Seq.filter (String.IsNullOrEmpty >> not) |> Seq.toArray + let noPrefix = String.IsNullOrEmpty logPrefix + // We want to redirect output if + // 1. Use want to add prefix to the output + // 2. User asked to not print output + // 3. User asked to capture output + let shouldRedirectOutput = not noPrefix || not printOutput || captureOutput + + // By default, we don't redirect output because redirecting the + // output lose the color information. + if shouldRedirectOutput then + // We redirect both standard output and error output + // because some process write mixed output to both... + // This should in theory avoid losing information because of the redirection. + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + startInfo.StandardOutputEncoding <- Encoding.UTF8 + startInfo.StandardErrorEncoding <- Encoding.UTF8 + + use result = Process.Start startInfo + let standardOutputSb = StringBuilder() + let standardErrorSb = StringBuilder() + + let mask (line: string) = Array.fold (fun (line: string) (value: string) -> line.Replace(value, "*")) line maskedValues + + // Both pumps used to call Console.WriteLine, a single writer, so writes were serialized for + // free. Routing stderr to Console.Error gives that up: the two are separate SyncTextWriter + // instances, each locking on itself, so one line can be cut in half by the other. This lock + // puts it back. It does not make the order between the two streams deterministic; they are + // pumped by separate threads and the OS decides who wins. + let consoleLock = obj () + + let handleDataReceived (sb: StringBuilder) (out: TextWriter) (ev: DataReceivedEventArgs) = + // Data is null once the stream is closed + if ev.Data <> null then + if captureOutput then sb.AppendLine ev.Data |> ignore + if printOutput && not (String.IsNullOrEmpty ev.Data) then + let line = mask ev.Data + lock consoleLock (fun () -> if noPrefix then out.WriteLine(line) else out.WriteLine(logPrefix + " " + line)) + + if shouldRedirectOutput then + result.OutputDataReceived.Add(handleDataReceived standardOutputSb Console.Out) + // The child's stderr goes to our stderr. Sending it to stdout, as this did before, + // empties err.txt for `... >out.txt 2>err.txt` and hides failures from any CI wrapper + // that classifies a run by whether stderr is non-empty. + result.ErrorDataReceived.Add(handleDataReceived standardErrorSb Console.Error) + + use! cd = + Async.OnCancel(fun _ -> + AnsiConsole.Markup $"[yellow]{logPrefix}[/] " + AnsiConsole.WriteLine $"{commandLogString}: is cancelled or timed out and the process will be killed." + Native.kill result + ) + + use _ = + match cancellationToken with + | Some ct -> + ct.Register(fun () -> + AnsiConsole.MarkupLine("[yellow]Command is cancelled by your token[/]") Native.kill result ) - - use _ = + :> IDisposable + | _ -> { + new IDisposable with + member _.Dispose() = () + } + + if shouldRedirectOutput then + result.BeginOutputReadLine() + result.BeginErrorReadLine() + + result.WaitForExit() + + return { + ExitCode = result.ExitCode + StandardOutput = standardOutputSb.ToString() + StandardError = standardErrorSb.ToString() + IsCancelled = match cancellationToken with - | Some ct -> - ct.Register(fun () -> - AnsiConsole.MarkupLine("[yellow]Command is cancelled by your token[/]") - Native.kill result - ) - :> IDisposable - | _ -> - { new IDisposable with - member _.Dispose() = () - } - - if shouldRedirectOutput then - result.BeginOutputReadLine() - result.BeginErrorReadLine() - - result.WaitForExit() - - return { - ExitCode = result.ExitCode - StandardOutput = standardOutputSb.ToString() - StandardError = standardErrorSb.ToString() - } + | None -> false + | Some ct -> ct.IsCancellationRequested } + } diff --git a/Fun.Build/StageBuilder.fs b/Fun.Build/StageBuilder.fs index 762ff0c..7d5c40e 100644 --- a/Fun.Build/StageBuilder.fs +++ b/Fun.Build/StageBuilder.fs @@ -50,25 +50,25 @@ type StageBuilder(name: string) = member inline _.Yield([] builder: BuildStep) = builder member inline _.Delay([] fn: unit -> BuildStep) = - BuildStage(fun ctx -> - { ctx with + BuildStage(fun ctx -> { + ctx with Steps = ctx.Steps @ [ Step.StepFn(fn().Invoke) ] - } - ) + }) member inline _.Combine([] builder: BuildStep, [] build: BuildStage) = BuildStage(fun ctx -> - build.Invoke - { ctx with + build.Invoke { + ctx with Steps = ctx.Steps @ [ Step.StepFn builder.Invoke ] - } + } ) member inline _.For([] build: BuildStage, [] fn: unit -> BuildStep) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = ctx.Steps @ [ Step.StepFn(fn().Invoke) ] + { + ctx with + Steps = ctx.Steps @ [ Step.StepFn(fn().Invoke) ] } ) @@ -94,8 +94,9 @@ type StageBuilder(name: string) = member inline _.envVars([] build: BuildStage, kvs: seq) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - EnvVars = kvs |> Seq.fold (fun state (k, v) -> Map.add k v state) ctx.EnvVars + { + ctx with + EnvVars = kvs |> Seq.fold (fun state (k, v) -> Map.add k v state) ctx.EnvVars } ) @@ -158,9 +159,10 @@ type StageBuilder(name: string) = BuildStage(fun ctx -> let ctx = build.Invoke ctx let shouldContinue = defaultArg flag true - { ctx with - ContinueStepsOnFailure = shouldContinue - ContinueStageOnFailure = shouldContinue + { + ctx with + ContinueStepsOnFailure = shouldContinue + ContinueStageOnFailure = shouldContinue } ) @@ -171,8 +173,9 @@ type StageBuilder(name: string) = member inline _.timeout([] build: BuildStage, seconds: int) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Timeout = ValueSome(TimeSpan.FromSeconds seconds) + { + ctx with + Timeout = ValueSome(TimeSpan.FromSeconds seconds) } ) @@ -189,8 +192,9 @@ type StageBuilder(name: string) = member inline _.timeoutForStep([] build: BuildStage, seconds: int) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - TimeoutForStep = ValueSome(TimeSpan.FromSeconds seconds) + { + ctx with + TimeoutForStep = ValueSome(TimeSpan.FromSeconds seconds) } ) @@ -218,11 +222,10 @@ type StageBuilder(name: string) = /// Set if the steps in current stage should run in concurrent. Alias of paralle. [] member inline _.concurrent([] build: BuildStage, ?value: bool) = - BuildStage(fun ctx -> - { build.Invoke ctx with + BuildStage(fun ctx -> { + build.Invoke ctx with IsParallel = fun _ -> defaultArg value true - } - ) + }) /// Set workding dir for all steps under the stage. @@ -266,15 +269,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, buildStep: StageContext -> BuildStep) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, i) -> async { - let builder = buildStep ctx - return! builder.Invoke(ctx, i) - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, i) -> async { + let builder = buildStep ctx + return! builder.Invoke(ctx, i) + }) + ] } ) @@ -335,14 +339,15 @@ type StageBuilder(name: string) = member inline _.runSensitive([] build: BuildStage, command: FormattableString, ?cancellationToken: CancellationToken) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, step) -> - ctx.RunSensitiveCommand(command, step, cancellationToken = defaultArg cancellationToken CancellationToken.None) - ) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, step) -> + ctx.RunSensitiveCommand(command, step, cancellationToken = defaultArg cancellationToken CancellationToken.None) + ) + ] } ) @@ -352,15 +357,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: Async) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun _ -> async { - do! step - return Ok() - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun _ -> async { + do! step + return Ok() + }) + ] } ) @@ -369,15 +375,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: Async) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - let! exitCode = step - return ctx.MapExitCodeToResult exitCode - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + let! exitCode = step + return ctx.MapExitCodeToResult exitCode + }) + ] } ) @@ -387,15 +394,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> unit) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - step ctx - return Ok() - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + step ctx + return Ok() + }) + ] } ) @@ -404,8 +412,9 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> int) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> async { return ctx.MapExitCodeToResult(step ctx) }) ] + { + ctx with + Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> async { return ctx.MapExitCodeToResult(step ctx) }) ] } ) @@ -415,15 +424,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Async) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - do! step ctx - return Ok() - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + do! step ctx + return Ok() + }) + ] } ) @@ -432,15 +442,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Async) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - let! exitCode = step ctx - return ctx.MapExitCodeToResult exitCode - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + let! exitCode = step ctx + return ctx.MapExitCodeToResult exitCode + }) + ] } ) @@ -450,8 +461,9 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Async>) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> step ctx) ] + { + ctx with + Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> step ctx) ] } ) @@ -460,8 +472,9 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Task>) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> step ctx |> Async.AwaitTask) ] + { + ctx with + Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> step ctx |> Async.AwaitTask) ] } ) @@ -470,8 +483,9 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Result) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> async { return step ctx }) ] + { + ctx with + Steps = ctx.Steps @ [ Step.StepFn(fun (ctx, _) -> async { return step ctx }) ] } ) @@ -481,15 +495,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Task) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - do! step ctx |> Async.AwaitTask - return Ok() - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + do! step ctx |> Async.AwaitTask + return Ok() + }) + ] } ) @@ -498,15 +513,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Task) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - do! step ctx |> Async.AwaitTask - return Ok() - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + do! step ctx |> Async.AwaitTask + return Ok() + }) + ] } ) @@ -515,15 +531,16 @@ type StageBuilder(name: string) = member _.run(build: BuildStage, step: StageContext -> Task) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> async { - let! exitCode = step ctx |> Async.AwaitTask - return ctx.MapExitCodeToResult exitCode - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> async { + let! exitCode = step ctx |> Async.AwaitTask + return ctx.MapExitCodeToResult exitCode + }) + ] } ) @@ -533,18 +550,19 @@ type StageBuilder(name: string) = member _.runHttpHealthCheck(build: BuildStage, url: string, ?configRequest, ?cancellationToken) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, _) -> - ctx.RunHttpHealthCheck( - url, - configRequest = defaultArg configRequest ignore, - cancellationToken = defaultArg cancellationToken CancellationToken.None + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, _) -> + ctx.RunHttpHealthCheck( + url, + configRequest = defaultArg configRequest ignore, + cancellationToken = defaultArg cancellationToken CancellationToken.None + ) ) - ) - ] + ] } ) @@ -554,18 +572,19 @@ type StageBuilder(name: string) = member inline _.echo([] build: BuildStage, msg: StageContext -> string) = BuildStage(fun ctx -> let ctx = build.Invoke ctx - { ctx with - Steps = - ctx.Steps - @ [ - Step.StepFn(fun (ctx, i) -> async { - if ctx.GetNoPrefixForStep() then - printfn "%s" (msg ctx) - else - printfn "%s %s" (ctx.BuildStepPrefix i) (msg ctx) - return Ok() - }) - ] + { + ctx with + Steps = + ctx.Steps + @ [ + Step.StepFn(fun (ctx, i) -> async { + if ctx.GetNoPrefixForStep() then + printfn "%s" (msg ctx) + else + printfn "%s %s" (ctx.BuildStepPrefix i) (msg ctx) + return Ok() + }) + ] } ) diff --git a/Fun.Build/StageContextExtensions.fs b/Fun.Build/StageContextExtensions.fs index 201c059..559d9bf 100644 --- a/Fun.Build/StageContextExtensions.fs +++ b/Fun.Build/StageContextExtensions.fs @@ -138,23 +138,6 @@ module StageContextExtensionsInternal = member ctx.BuildIndent(?margin) = String(' ', ctx.GetNamePath().Length - ctx.Name.Length + defaultArg margin 4) - /// Verify if the exit code is allowed. - member stage.IsAcceptableExitCode(exitCode: int) : bool = - let parentAcceptableExitCodes = - match stage.ParentContext with - | ValueNone -> Set.empty - | ValueSome(StageParent.Pipeline pipeline) -> pipeline.AcceptableExitCodes - | ValueSome(StageParent.Stage parentStage) -> parentStage.AcceptableExitCodes - - Set.contains exitCode stage.AcceptableExitCodes || Set.contains exitCode parentAcceptableExitCodes - - member stage.MapExitCodeToResult(exitCode: int) = - if stage.IsAcceptableExitCode exitCode then - Ok() - else - Error "Exit code is not indicating as successful." - - /// Run the stage. If index is not provided then it will be treated as sub-stage. member stage.Run(index: StageIndex, cancellationToken: CancellationToken) = let mutable isSuccess = true @@ -170,13 +153,13 @@ module StageContextExtensionsInternal = if not isActive && stage.FailIfIgnored then let msg = $"Stage ({stage.GetNamePath()}) cannot be ignored (inactive)" AnsiConsole.MarkupLineInterpolated $"[red]{msg}[/]" - let verifyStage = - { stage with + let verifyStage = { + stage with ParentContext = match stage.ParentContext with | ValueSome(StageParent.Pipeline p) -> ValueSome(StageParent.Pipeline { p with Mode = Mode.Verification }) | x -> x - } + } stage.IsActive(verifyStage) |> ignore raise (PipelineFailedException msg) @@ -236,10 +219,10 @@ module StageContextExtensionsInternal = match step with | Step.StepFn _ -> stage.BuildStepPrefix(i) | Step.StepOfStage s -> - let subStage = - { s with + let subStage = { + s with ParentContext = ValueSome(StageParent.Stage stage) - } + } subStage.BuildCurrentStepPrefix() + ">" @@ -265,10 +248,10 @@ module StageContextExtensionsInternal = | Ok _ -> return true } | Step.StepOfStage subStage -> async { - let subStage = - { subStage with + let subStage = { + subStage with ParentContext = ValueSome(StageParent.Stage stage) - } + } let isSuccess, es = subStage.Run(StageIndex.Step i, linkedStepCTS.Token) exns.AddRange es return isSuccess @@ -375,8 +358,7 @@ module StageContextExtensionsInternal = ) | StageIndex.Stage i -> AnsiConsole.Write( - Rule($"""[grey50]STAGE #{i} [bold {color}]{namePath}[/] finished. {stageSW.ElapsedMilliseconds}ms.[/]""") - .LeftJustified() + Rule($"""[grey50]STAGE #{i} [bold {color}]{namePath}[/] finished. {stageSW.ElapsedMilliseconds}ms.[/]""").LeftJustified() ) | StageIndex.Step _ -> AnsiConsole.MarkupLineInterpolated( @@ -405,16 +387,17 @@ module StageContextExtensionsInternal = let inline buildStageIsActive ([] build: BuildStage) ([] conditionFn) = BuildStage(fun ctx -> let newCtx = build.Invoke ctx - { newCtx with - IsActive = - fun ctx -> - match ctx.GetMode() with - | Mode.Execution -> newCtx.IsActive ctx && conditionFn ctx - | Mode.Verification - | Mode.CommandHelp _ -> - newCtx.IsActive ctx |> ignore - conditionFn ctx |> ignore - false + { + newCtx with + IsActive = + fun ctx -> + match ctx.GetMode() with + | Mode.Execution -> newCtx.IsActive ctx && conditionFn ctx + | Mode.Verification + | Mode.CommandHelp _ -> + newCtx.IsActive ctx |> ignore + conditionFn ctx |> ignore + false } ) @@ -424,6 +407,23 @@ module StageContextExtensions = type StageContext with + /// Verify if the exit code is allowed. + member stage.IsAcceptableExitCode(exitCode: int) : bool = + let parentAcceptableExitCodes = + match stage.ParentContext with + | ValueNone -> Set.empty + | ValueSome(StageParent.Pipeline pipeline) -> pipeline.AcceptableExitCodes + | ValueSome(StageParent.Stage parentStage) -> parentStage.AcceptableExitCodes + + Set.contains exitCode stage.AcceptableExitCodes || Set.contains exitCode parentAcceptableExitCodes + + member stage.MapExitCodeToResult(exitCode: int) = + if stage.IsAcceptableExitCode exitCode then + Ok() + else + Error "Exit code is not indicating as successful." + + /// Stage under pipeline should be level 0, the level will get increased for nested stages member ctx.GetStageLevel() = match ctx.ParentContext with diff --git a/Fun.Build/Types.fs b/Fun.Build/Types.fs index 2de1ac4..f61e051 100644 --- a/Fun.Build/Types.fs +++ b/Fun.Build/Types.fs @@ -19,6 +19,10 @@ type CommandOutput = { ExitCode: int StandardOutput: string StandardError: string + /// True when the command was killed because your cancellation token fired. Without this a + /// cancelled command is indistinguishable from a genuine failure: it comes back as 143 on Linux + /// or -1 on Windows, with whatever output it had managed to produce. + IsCancelled: bool } @@ -34,13 +38,12 @@ type CmdName = | LongName x -> [ x ] | FullName(s, l) -> [ s; l ] -type CmdArg = - { - Name: CmdName - Values: string list - Description: string option - IsOptional: bool - } +type CmdArg = { + Name: CmdName + Values: string list + Description: string option + IsOptional: bool +} with static member Create(?shortName: string, ?longName: string, ?description: string, ?values, ?isOptional: bool) = { Name = @@ -60,13 +63,12 @@ type CmdArg = member this.WithOptional x = { this with IsOptional = x } -type EnvArg = - { - Name: string - Values: string list - Description: string option - IsOptional: bool - } +type EnvArg = { + Name: string + Values: string list + Description: string option + IsOptional: bool +} with static member Create(name: string, ?description: string, ?values, ?isOptional: bool) = { Name = name diff --git a/Fun.Build/Windows.fs b/Fun.Build/Windows.fs index 896bf58..0feb54c 100644 --- a/Fun.Build/Windows.fs +++ b/Fun.Build/Windows.fs @@ -12,33 +12,32 @@ module Windows = appName = EnvArg.Create("IIS_RECYCLE_APP_NAME") |} - let stage_deployToIIS (zippedPackageFile: string) = - stage "deploy to iis" { - whenEnvVar deployToIISOptions.host - whenEnvVar deployToIISOptions.user - whenEnvVar deployToIISOptions.pwd - whenEnvVar deployToIISOptions.siteName - whenEnvVar deployToIISOptions.targetDir - whenEnvVar deployToIISOptions.appName - run (fun ctx -> asyncResult { - let host = ctx.GetEnvVar deployToIISOptions.host.Name - let user = ctx.GetEnvVar deployToIISOptions.user.Name - let pwd = ctx.GetEnvVar deployToIISOptions.pwd.Name - let siteName = ctx.GetEnvVar deployToIISOptions.siteName.Name - let targetDir = ctx.GetEnvVar deployToIISOptions.targetDir.Name - let appName = ctx.GetEnvVar deployToIISOptions.appName.Name + let stage_deployToIIS (zippedPackageFile: string) = stage "deploy to iis" { + whenEnvVar deployToIISOptions.host + whenEnvVar deployToIISOptions.user + whenEnvVar deployToIISOptions.pwd + whenEnvVar deployToIISOptions.siteName + whenEnvVar deployToIISOptions.targetDir + whenEnvVar deployToIISOptions.appName + run (fun ctx -> asyncResult { + let host = ctx.GetEnvVar deployToIISOptions.host.Name + let user = ctx.GetEnvVar deployToIISOptions.user.Name + let pwd = ctx.GetEnvVar deployToIISOptions.pwd.Name + let siteName = ctx.GetEnvVar deployToIISOptions.siteName.Name + let targetDir = ctx.GetEnvVar deployToIISOptions.targetDir.Name + let appName = ctx.GetEnvVar deployToIISOptions.appName.Name - let msdeploy (x: string) = ctx.RunSensitiveCommand($"'C:/Program Files (x86)/IIS/Microsoft Web Deploy V3/msdeploy.exe' {x}") + let msdeploy (x: string) = ctx.RunSensitiveCommand($"'C:/Program Files (x86)/IIS/Microsoft Web Deploy V3/msdeploy.exe' {x}") - do! - msdeploy - $"-verb:sync -allowUntrusted -source:recycleApp -dest:recycleApp=\"{appName}\",recycleMode=\"StopAppPool\",computerName=\"{host}/msdeploy.axd?site={siteName}\",username=\"{user}\",password=\"{pwd}\",AuthType=\"Basic\"" + do! + msdeploy + $"-verb:sync -allowUntrusted -source:recycleApp -dest:recycleApp=\"{appName}\",recycleMode=\"StopAppPool\",computerName=\"{host}/msdeploy.axd?site={siteName}\",username=\"{user}\",password=\"{pwd}\",AuthType=\"Basic\"" - do! - msdeploy - $"-verb:sync -allowUntrusted -source:package=\"{zippedPackageFile}\" -dest:contentPath=\"{targetDir}\",computerName=\"{host}/msdeploy.axd?site={siteName}\",username=\"{user}\",password=\"{pwd}\",AuthType=\"Basic\" -enableRule:DoNotDeleteRule" - do! - msdeploy - $"-verb:sync -allowUntrusted -source:recycleApp -dest:recycleApp=\"{appName}\",recycleMode=\"StartAppPool\",computerName=\"{host}/msdeploy.axd?site={siteName}\",username=\"{user}\",password=\"{pwd}\",AuthType=\"Basic\"" - }) - } + do! + msdeploy + $"-verb:sync -allowUntrusted -source:package=\"{zippedPackageFile}\" -dest:contentPath=\"{targetDir}\",computerName=\"{host}/msdeploy.axd?site={siteName}\",username=\"{user}\",password=\"{pwd}\",AuthType=\"Basic\" -enableRule:DoNotDeleteRule" + do! + msdeploy + $"-verb:sync -allowUntrusted -source:recycleApp -dest:recycleApp=\"{appName}\",recycleMode=\"StartAppPool\",computerName=\"{host}/msdeploy.axd?site={siteName}\",username=\"{user}\",password=\"{pwd}\",AuthType=\"Basic\"" + }) + } diff --git a/README.md b/README.md index 0e1d847..de1d937 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,11 @@ let demo1 = do! ctx.RunCommand "dotnet --version" }) // You can also capture the exit code, standard output and standard error and decide yourself what to do with them + // Note an async { } block returning unit always reports success, so return a Result if the exit code should decide run (fun ctx -> async { let! output = ctx.RunCommandCaptureAll "dotnet --version" printfn "exit code %d, stdout %s, stderr %s" output.ExitCode output.StandardOutput output.StandardError + return ctx.MapExitCodeToResult output.ExitCode }) // You can run async functions run (Async.Sleep 1000) diff --git a/build.fsx b/build.fsx index 3ba2c80..75b5e71 100644 --- a/build.fsx +++ b/build.fsx @@ -18,10 +18,10 @@ let stage_checkEnv = stage "Check environment" { run "dotnet tool restore" } let stage_lint = stage "Lint" { - stage "Format" { run "dotnet fantomas . -r" } + stage "Format" { run "dotnet fantomas" } stage "Check" { whenGithubAction - run "dotnet fantomas . -r --check" + run "dotnet fantomas check" } } diff --git a/demo.fsx b/demo.fsx index 9ba4b48..147507b 100644 --- a/demo.fsx +++ b/demo.fsx @@ -21,10 +21,21 @@ let demo1 = run (fun ctx -> "dotnet --version") run (fun ctx -> async { return "dotnet --version" }) // You use use the RunCommand to run multiple command according to your logics - run (fun ctx -> asyncResult { - do! ctx.RunCommand "dotnet --version" - do! ctx.RunCommand "dotnet --version" - }) + run (fun ctx -> + asyncResult { + do! ctx.RunCommand "dotnet --version" + do! ctx.RunCommand "dotnet --version" + } + ) + // You can also capture the exit code, standard output and standard error and decide yourself what to do with them + // Note an async { } block returning unit always reports success, so return a Result if the exit code should decide + run (fun ctx -> + async { + let! output = ctx.RunCommandCaptureAll "dotnet --version" + printfn "exit code %d, stdout %s, stderr %s" output.ExitCode output.StandardOutput output.StandardError + return ctx.MapExitCodeToResult output.ExitCode + } + ) // You can run async functions run (Async.Sleep 1000) run (fun _ -> Async.Sleep 1000) @@ -106,9 +117,11 @@ pipeline "Fun.Build" { stage "Post stage" { echo "You are finished 😂" echo (fun ctx -> sprintf "You are finished here: %A" (ctx.GetWorkingDir())) - run (fun _ -> async { - return 0 // do something - }) + run (fun _ -> + async { + return 0 // do something + } + ) } ] // You can have multiple pipelines, sometimes you only want to run it only if the command specified the pipeline name. diff --git a/global.json b/global.json new file mode 100644 index 0000000..01cb658 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature" + } +}