diff --git a/.gitignore b/.gitignore index be0d1c140..06a71992f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ external -nuke-global.* +fallout-global.* # Build Folders (you can keep bin if you'd like, to store dlls and pdbs) [Bb]in/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..577166d43 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Prompts you for the command/args each launch, e.g. ":get-configuration" or ":bogus". + "name": "fallout (ask for args)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": "${input:falloutArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + // Dispatch error path — prints the command manifest, no side effects. + "name": "fallout :bogus", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": [":bogus"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, + { + // Real command via the DelegateCommand path — read-only. + "name": "fallout :get-configuration", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": [":get-configuration"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ], + "inputs": [ + { + "id": "falloutArgs", + "type": "promptString", + "description": "Arguments to pass to fallout (space-separated), e.g. ':get-configuration'", + "default": ":bogus" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..e19bb9547 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-cli", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Fallout.Cli/Fallout.Cli.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Directory.Build.targets b/Directory.Build.targets index 88ac0bd7c..8dadf10e0 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -4,7 +4,7 @@ true - $(MSBuildThisFileDirectory)\nuke-global.sln + $(MSBuildThisFileDirectory)fallout-global.sln diff --git a/Directory.Packages.props b/Directory.Packages.props index 644d92315..5a1a8e0e3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/README.md b/README.md index 08d2d197a..f173fdc2d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Multi-provider CI support (Azure Pipelines, GitLab, TeamCity, AppVeyor) was remo ### Commits, issues, PRs (rolling 30 days) -![Repobeats analytics image](https://repobeats.axiom.co/api/embed/13bc62ac87b75cea7be4bdbbcba90af620665333.svg "Repobeats analytics image") +![Repobeats analytics image](https://repobeats.axiom.co/api/embed/c4ea2e2211409a86c7dba874c3ed6aa629efe700.svg "Repobeats analytics image") Generated by [Repobeats](https://repobeats.axiom.co). diff --git a/build/Build.GlobalSolution.cs b/build/Build.GlobalSolution.cs index 7efdea75f..f6878b4b3 100644 --- a/build/Build.GlobalSolution.cs +++ b/build/Build.GlobalSolution.cs @@ -16,7 +16,7 @@ partial class Build { [Parameter] readonly bool UseHttps; - AbsolutePath GlobalSolution => RootDirectory / "nuke-global.sln"; + AbsolutePath GlobalSolution => RootDirectory / "fallout-global.sln"; AbsolutePath ExternalRepositoriesDirectory => RootDirectory / "external"; AbsolutePath ExternalRepositoriesFile => ExternalRepositoriesDirectory / "repositories.yml"; diff --git a/docs/agents/conventions.md b/docs/agents/conventions.md index 4bd698142..fee972aec 100644 --- a/docs/agents/conventions.md +++ b/docs/agents/conventions.md @@ -70,7 +70,7 @@ Shaped by [milestone #18](https://github.com/Fallout-build/Fallout/milestone/18) - Don't add `secret`/`default` defaults to tool JSON files (see CONTRIBUTING.md). - Don't introduce a new test framework or assertion library — stay on xUnit + FluentAssertions + Verify. - Don't commit `output/` or any `bin/`/`obj/` directory. -- Don't commit `nuke-global.sln` or other `nuke-global.*` files — they're generated by `GenerateGlobalSolution`. +- Don't commit `fallout-global.sln` or other `fallout-global.*` files — they're generated by `GenerateGlobalSolution`. - Don't bypass `Directory.Packages.props` or `Directory.Build.targets`. - Don't reintroduce `.editorconfig` or `*.DotSettings` without a maintainer-level decision — they were intentionally removed. diff --git a/src/Fallout.Build/Utilities/ConsoleUtility.cs b/src/Fallout.Build/Utilities/ConsoleUtility.cs index 1e59cb972..7d166d767 100644 --- a/src/Fallout.Build/Utilities/ConsoleUtility.cs +++ b/src/Fallout.Build/Utilities/ConsoleUtility.cs @@ -47,15 +47,13 @@ public static string PromptForInput(string question, string defaultValue) } key = Console.ReadKey(intercept: true); - if (ConsoleKey.A <= key.Key && key.Key <= ConsoleKey.Z - || ConsoleKey.D0 <= key.Key && key.Key <= ConsoleKey.D9 - || new[] { '.', '/', '\\', '_', '-' }.Any(x => x == key.KeyChar)) + if (key.IsValidInputKey()) input.Append(key.KeyChar); else if (key.Key == ConsoleKey.Backspace && input.Length > 0) input.Remove(input.Length - 1, length: 1); else if (key.Key == InterruptKey) s_interrupted = true; - } while (!(key.Key == ConfirmationKey || key.Key == InterruptKey)); + } while (key.Key is not (ConfirmationKey or InterruptKey)); var result = input.Length > 0 ? input.ToString() : defaultValue; Console.CursorLeft = 0; diff --git a/src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs b/src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs new file mode 100644 index 000000000..b080d8a93 --- /dev/null +++ b/src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs @@ -0,0 +1,17 @@ +using System; +using System.Linq; + +namespace Fallout.Common.Utilities; + +public static class ConsoleUtilityExtensions +{ + private static readonly char[] AllowedSpecialCharacters = ['.', '/', '\\', '_', '-']; + + public static bool IsValidInputKey(this ConsoleKeyInfo key) + { + return key.Key is >= ConsoleKey.A and <= ConsoleKey.Z + || key.Key is >= ConsoleKey.D0 and <= ConsoleKey.D9 + || AllowedSpecialCharacters.Any(x => x == key.KeyChar) + || char.IsLetterOrDigit(key.KeyChar); + } +} diff --git a/src/Fallout.Cli/CommandDispatcher.cs b/src/Fallout.Cli/CommandDispatcher.cs new file mode 100644 index 000000000..11948a394 --- /dev/null +++ b/src/Fallout.Cli/CommandDispatcher.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; + +namespace Fallout.Cli; + +/// +/// Routes a command-line invocation to the matching . Commands are +/// resolved by from the registered set — dash- and +/// case-insensitively, preserving every spelling the historical reflection dispatch accepted +/// (e.g. :add-package and :addpackage, :PopDirectory and :popdirectory). +/// +internal sealed class CommandDispatcher +{ + private const char CommandPrefix = ':'; + + private readonly IReadOnlyList commands; + private readonly IConsolePrompts prompts; + + public CommandDispatcher(IEnumerable commands, IConsolePrompts prompts) + { + this.commands = commands.ToList(); + this.prompts = prompts; + } + + public async Task DispatchAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; + if (hasCommand) + { + var token = args.First().Trim(CommandPrefix); + if (string.IsNullOrWhiteSpace(token)) + { + Assert.Fail($"No command specified. Usage is: fallout {CommandPrefix} [args]"); + } + + var command = Resolve(token); + return await command.ExecuteAsync(args.Skip(count: 1).ToArray(), rootDirectory, buildScript); + } + + if (rootDirectory == null) + { + return prompts.PromptForConfirmation( + $"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") + ? await GetRequired("setup").ExecuteAsync(Array.Empty(), rootDirectory: null, buildScript: null) + : 0; + } + + // TODO: docker + + return await GetRequired("run").ExecuteAsync(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); + } + + private IFalloutCommand Resolve(string token) + { + return commands.SingleOrDefault(x => Normalize(x.Name).EqualsOrdinalIgnoreCase(Normalize(token))) + .NotNull(new[] { $"Command '{token}' is not supported, available commands are:" } + .Concat(commands.Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); + } + + private IFalloutCommand GetRequired(string name) + => commands.Single(x => x.Name.EqualsOrdinalIgnoreCase(name)); + + private static string Normalize(string value) => value.Replace("-", string.Empty); +} diff --git a/src/Fallout.Cli/Commands/DelegateCommand.cs b/src/Fallout.Cli/Commands/DelegateCommand.cs new file mode 100644 index 000000000..97089fab1 --- /dev/null +++ b/src/Fallout.Cli/Commands/DelegateCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// Transitional adapter that exposes a not-yet-extracted Program.X handler as an +/// , so the dispatcher can route every command uniformly through the +/// registry while the per-command conversion (issue #392) lands one PR at a time. Each conversion +/// replaces one registration of this adapter with a real command type; the adapter is deleted once +/// the last legacy handler is gone. +/// +internal sealed class DelegateCommand : IFalloutCommand +{ + private readonly Func handler; + + public DelegateCommand(string name, Func handler) + { + Name = name; + this.handler = handler; + } + + public string Name { get; } + + // The adapted legacy handlers are still synchronous; each #392 conversion replaces one + // registration of this adapter with a command type that overrides ExecuteAsync for real. + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(handler(args, rootDirectory, buildScript)); +} diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs new file mode 100644 index 000000000..f583aa87d --- /dev/null +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// A single global-tool command (e.g. fallout :run, fallout :setup). +/// One command = one type, resolved by from the dependency-injection +/// container — replacing the historical reflection-over-Program dispatch. +/// +/// +/// This surface is intentionally minimal. It is not a stable public plugin contract yet; +/// when a public command SDK lands (milestone #7) the API will be annotated and versioned +/// explicitly. Until then, treat additions here as internal-by-convention. +/// +internal interface IFalloutCommand +{ + /// + /// The command name as typed after the : prefix (prefer dash form, e.g. "add-package").\ + /// Matched case-insensitively and with dashes ignored (so :addpackage also matches).\ + /// Legacy commands may still use PascalCase names (e.g. "GetNextDirectory"). + string Name { get; } + + /// + /// Executes the command and returns the process exit code. + /// + /// The arguments following the command token. + /// The resolved repository root, or null when none was found. + /// The resolved build script / project file, or null when none applies. + Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript); +} diff --git a/src/Fallout.Cli/Program.Run.cs b/src/Fallout.Cli/Commands/RunCommand.cs similarity index 77% rename from src/Fallout.Cli/Program.Run.cs rename to src/Fallout.Cli/Commands/RunCommand.cs index a9aefc998..dd6b66218 100644 --- a/src/Fallout.Cli/Program.Run.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -3,31 +3,42 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; using static Fallout.Common.Constants; -namespace Fallout.Cli; +namespace Fallout.Cli.Commands; -partial class Program +/// +/// fallout :run (and the default, command-less invocation): builds the build project and +/// runs it, forwarding any remaining arguments to the build. +/// +public sealed class RunCommand : IFalloutCommand { - private static int Run(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) + public string Name => "run"; + + public async Task ExecuteAsync(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) { var dotnet = ResolveDotnet(rootDirectory); - var buildExitCode = StartDotnet(dotnet, GetBuildArguments(buildProjectFile)); + var buildExitCode = await StartDotnetAsync(dotnet, GetBuildArguments(buildProjectFile)); if (buildExitCode != 0) + { return buildExitCode; + } - return StartDotnet(dotnet, GetRunArguments(buildProjectFile, forwardedArgs)); + return await StartDotnetAsync(dotnet, GetRunArguments(buildProjectFile, forwardedArgs)); } private static string ResolveDotnet(AbsolutePath rootDirectory) { var pathDotnet = TryGetDotnetFromPath(); if (pathDotnet != null) + { return pathDotnet; + } var shimDirectoryName = EnvironmentInfo.IsWin ? "dotnet-win" : "dotnet-unix"; var shimExecutableName = EnvironmentInfo.IsWin ? "dotnet.exe" : "dotnet"; @@ -48,7 +59,7 @@ private static string TryGetDotnetFromPath() .FirstOrDefault(File.Exists); } - private static int StartDotnet(string dotnet, IEnumerable arguments) + private static async Task StartDotnetAsync(string dotnet, IEnumerable arguments) { var startInfo = new ProcessStartInfo { @@ -57,17 +68,19 @@ private static int StartDotnet(string dotnet, IEnumerable arguments) }; foreach (var argument in arguments) + { startInfo.ArgumentList.Add(argument); + } startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; startInfo.Environment["DOTNET_NOLOGO"] = "1"; startInfo.Environment["DOTNET_ROLL_FORWARD"] = "Major"; startInfo.Environment["FALLOUT_TELEMETRY_OPTOUT"] = "1"; - startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(Program).Assembly.GetVersionText(); + startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(RunCommand).Assembly.GetVersionText(); startInfo.Environment[GlobalToolStartTimeEnvironmentKey] = DateTime.Now.ToString("O"); var process = Process.Start(startInfo).NotNull(); - process.WaitForExit(); + await process.WaitForExitAsync(); return process.ExitCode; } diff --git a/src/Fallout.Cli/Fallout.Cli.csproj b/src/Fallout.Cli/Fallout.Cli.csproj index 996a07f13..60071de75 100644 --- a/src/Fallout.Cli/Fallout.Cli.csproj +++ b/src/Fallout.Cli/Fallout.Cli.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Fallout.Cli/Program.AddPackage.cs b/src/Fallout.Cli/Program.AddPackage.cs index ea6d28cf5..29efb84be 100644 --- a/src/Fallout.Cli/Program.AddPackage.cs +++ b/src/Fallout.Cli/Program.AddPackage.cs @@ -45,7 +45,7 @@ public static int AddPackage(string[] args, AbsolutePath rootDirectory, Absolute return 0; } - private static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) + internal static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) { var buildProject = ProjectModelTasks.ParseProject(buildProjectFile).NotNull(); diff --git a/src/Fallout.Cli/Program.GetConfiguration.cs b/src/Fallout.Cli/Program.GetConfiguration.cs index 6600b3273..9a9875d95 100644 --- a/src/Fallout.Cli/Program.GetConfiguration.cs +++ b/src/Fallout.Cli/Program.GetConfiguration.cs @@ -11,7 +11,9 @@ namespace Fallout.Cli; partial class Program { - private const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE); + // internal (not private): shared with AddPackage/Update/Cake; will move into a configuration + // service in the final #392 collapse PR. + internal const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE); private const string TEMP_DIRECTORY = nameof(TEMP_DIRECTORY); private const string DOTNET_GLOBAL_FILE = nameof(DOTNET_GLOBAL_FILE); private const string DOTNET_INSTALL_URL = nameof(DOTNET_INSTALL_URL); @@ -27,7 +29,7 @@ public static int GetConfiguration(string[] args, AbsolutePath rootDirectory, Ab return 0; } - private static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) + internal static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) { string ReplaceScriptDirectory(string value) => evaluate diff --git a/src/Fallout.Cli/Program.Setup.cs b/src/Fallout.Cli/Program.Setup.cs index 819afebce..ae74774ca 100644 --- a/src/Fallout.Cli/Program.Setup.cs +++ b/src/Fallout.Cli/Program.Setup.cs @@ -176,13 +176,13 @@ internal static void UpdateSolutionFileContent( "EndProject"); } - private static string[] GetTemplate(string templateName) + internal static string[] GetTemplate(string templateName) { return ResourceUtility.GetResourceAllLines($"templates.{templateName}"); } - private static void WriteBuildScripts( + internal static void WriteBuildScripts( AbsolutePath scriptDirectory, AbsolutePath rootDirectory) { @@ -239,7 +239,7 @@ void MakeExecutable(AbsolutePath scriptFile) } } - private static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) + internal static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) { var parametersFile = GetDefaultParametersFile(rootDirectory); var dictionary = new Dictionary { ["$schema"] = BuildSchemaFileName }; diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 828b31870..2221f1d9a 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -1,21 +1,22 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text; +using System.Threading.Tasks; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; -using Spectre.Console; +using Microsoft.Extensions.DependencyInjection; namespace Fallout.Cli; public partial class Program { - private const char CommandPrefix = ':'; + internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; - private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; - - private static int Main(string[] args) + private static async Task Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; @@ -28,7 +29,8 @@ private static int Main(string[] args) .FirstOrDefault(x => Constants.TryGetRootDirectoryFrom(x.Parent) == rootDirectory) : null; - return Handle(args, rootDirectory, buildScript); + using var services = BuildServiceProvider(); + return await services.GetRequiredService().DispatchAsync(args, rootDirectory, buildScript); } catch (Exception exception) { @@ -37,133 +39,70 @@ private static int Main(string[] args) } } - private static void PrintInfo() - { - Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); - } - - private static AbsolutePath TryGetRootDirectory() - { - // TODO: copied in FalloutBuild.GetRootDirectory - var parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); - if (parameterValue != null) - return parameterValue; - - if (EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName)) - return EnvironmentInfo.WorkingDirectory; - - return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory()); - } - - private static int Handle(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; - if (hasCommand) - { - var command = args.First().Trim(CommandPrefix).Replace("-", string.Empty); - if (string.IsNullOrWhiteSpace(command)) - Assert.Fail($"No command specified. Usage is: nuke {CommandPrefix} [args]"); - - var availableCommands = typeof(Program).GetMethods(ReflectionUtility.Static).Where(x => x.ReturnType == typeof(int)).ToList(); - var commandHandler = availableCommands.SingleOrDefault(x => x.Name.EqualsOrdinalIgnoreCase(command)) - .NotNull(new[] { $"Command '{command}' is not supported, available commands are:" } - .Concat(availableCommands.Where(x => x.IsPublic).Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); - // TODO: add assertions about return type and parameters - - var commandArguments = new object[] { args.Skip(count: 1).ToArray(), rootDirectory, buildScript }; - return (int)commandHandler.Invoke(obj: null, commandArguments).NotNull($"Command '{command}' did not return exit code"); - } - - if (rootDirectory == null) - { - return PromptForConfirmation($"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") - ? Setup(new string[0], rootDirectory: null, buildScript: null) - : 0; - } - - // TODO: docker - - return Run(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); - } - - private static void ShowInput(string emoji, string title, string value) + private static ServiceProvider BuildServiceProvider() { - AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); - } + var services = new ServiceCollection(); - private static void ShowCompletion(string title) - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); - } + services.AddSingleton(); + services.AddSingleton(); + RegisterCommands(services); - private static void ClearPreviousLine() - { - AnsiConsole.Cursor.MoveUp(); - Console.WriteLine(' '.Repeat(Console.WindowWidth)); - AnsiConsole.Cursor.MoveUp(); + return services.BuildServiceProvider(); } - private static bool PromptForConfirmation(string question) + private static void RegisterCommands(IServiceCollection services) { - return AnsiConsole.Confirm(question); + // Real command types — issue #392 converts one legacy handler per PR. + services.AddSingleton(); + + // Legacy handlers still living on Program, adapted until they are extracted into command + // types. Each conversion deletes one line here plus its Program.X.cs partial. + services.AddSingleton(new DelegateCommand("setup", Setup)); + services.AddSingleton(new DelegateCommand("update", Update)); + services.AddSingleton(new DelegateCommand("add-package", AddPackage)); + services.AddSingleton(new DelegateCommand("cake-convert", CakeConvert)); + services.AddSingleton(new DelegateCommand("cake-clean", CakeClean)); + services.AddSingleton(new DelegateCommand("complete", Complete)); + services.AddSingleton(new DelegateCommand("get-configuration", GetConfiguration)); + services.AddSingleton(new DelegateCommand("secrets", Secrets)); + services.AddSingleton(new DelegateCommand("trigger", Trigger)); + services.AddSingleton(new DelegateCommand("GetNextDirectory", (_, _, _) => GetNextDirectory())); + services.AddSingleton(new DelegateCommand("PopDirectory", (_, _, _) => PopDirectory())); + services.AddSingleton(new DelegateCommand("PushWithCurrentRootDirectory", (_, rootDirectory, _) => PushWithCurrentRootDirectory(rootDirectory))); + services.AddSingleton(new DelegateCommand("PushWithParentRootDirectory", (_, rootDirectory, _) => PushWithParentRootDirectory(rootDirectory))); + services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", (_, _, _) => PushWithChosenRootDirectory())); } - private static string PromptForInput(string question, string defaultValue = null) + internal static void PrintInfo() { - return AnsiConsole.Prompt( - new TextPrompt(question) - .DefaultValue(defaultValue)); + Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); } - private static string PromptForSecret(string title, int? minLength = null) + private static AbsolutePath TryGetRootDirectory() { - Assert.False(title.EndsWith(':')); + // TODO: copied in FalloutBuild.GetRootDirectory + AbsolutePath parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); + if (parameterValue != null) + return parameterValue; - return AnsiConsole.Prompt( - new TextPrompt($"{title}:") - .Secret() - .Validate(x => minLength == null || x.Length >= minLength, - message: $"Secret must be at least {minLength} characters long")); - } + if (EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName)) + return EnvironmentInfo.WorkingDirectory; - private static T PromptForChoice(string question, params (T Value, string Description)[] choices) - { - var choice = AnsiConsole.Prompt( - new SelectionPrompt() - .Title(question) - .HighlightStyle(new Style(Color.Turquoise2)) - .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description) - .AddChoices(choices.Select(x => x.Value))); - return choice; + return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory()); } - private static void ConfirmExecution(string title, Action action) - { - Assert.False(title.EndsWith('?')); - - var confirmation = PromptForConfirmation($"{title}?"); - ClearPreviousLine(); - - if (confirmation) - { - AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); - try - { - action.Invoke(); - } - catch (Exception) - { - confirmation = false; - title = $"{title} (failed)"; - } - finally - { - ClearPreviousLine(); - } - } - - var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); - AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); - } + // ── Transitional Spectre prompt delegators ────────────────────────────────────────────────── + // The implementations now live in SpectreConsolePrompts; these forwards keep the not-yet-extracted + // Program.X.cs command handlers compiling. As each handler becomes a command type taking + // IConsolePrompts via the constructor, its use of these disappears; the last conversion deletes them. + private static readonly IConsolePrompts s_prompts = new SpectreConsolePrompts(); + + private static void ShowInput(string emoji, string title, string value) => s_prompts.ShowInput(emoji, title, value); + private static void ShowCompletion(string title) => s_prompts.ShowCompletion(title); + private static void ClearPreviousLine() => s_prompts.ClearPreviousLine(); + private static bool PromptForConfirmation(string question) => s_prompts.PromptForConfirmation(question); + private static string PromptForInput(string question, string defaultValue = null) => s_prompts.PromptForInput(question, defaultValue); + private static string PromptForSecret(string title, int? minLength = null) => s_prompts.PromptForSecret(title, minLength); + private static T PromptForChoice(string question, params (T Value, string Description)[] choices) => s_prompts.PromptForChoice(question, choices); + private static void ConfirmExecution(string title, Action action) => s_prompts.ConfirmExecution(title, action); } diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs new file mode 100644 index 000000000..99b383b9b --- /dev/null +++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs @@ -0,0 +1,39 @@ +using System; + +namespace Fallout.Cli.Prompts; + +/// +/// Interactive console prompts and status rendering used by commands. Injected into commands +/// so their interaction logic can be unit-tested with a fake, instead of reaching the historical +/// static Spectre helpers on Program. The default implementation is +/// . +/// +internal interface IConsolePrompts +{ + /// Renders a labelled, read-only input line (used to echo resolved setup choices). + void ShowInput(string emoji, string title, string value); + + /// Renders a " completed!" banner. + void ShowCompletion(string title); + + /// Clears the previously written console line. + void ClearPreviousLine(); + + /// Asks a yes/no question and returns the answer. + bool PromptForConfirmation(string question); + + /// Asks for free-text input, optionally with a default value. + string PromptForInput(string question, string defaultValue = null); + + /// Asks for a masked secret value, optionally enforcing a minimum length. + string PromptForSecret(string title, int? minLength = null); + + /// Asks the user to pick one of the supplied . + T PromptForChoice(string question, params (T Value, string Description)[] choices); + + /// + /// Confirms, then runs with progress/completion rendering, + /// reporting success or failure without throwing. + /// + void ConfirmExecution(string title, Action action); +} diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs new file mode 100644 index 000000000..8013f2105 --- /dev/null +++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using Fallout.Common; +using Fallout.Common.Utilities; +using Spectre.Console; + +namespace Fallout.Cli.Prompts; + +/// +/// backed by . This is the single home for +/// the interactive prompt logic that previously lived as static helpers on Program. +/// +internal sealed class SpectreConsolePrompts : IConsolePrompts +{ + public void ShowInput(string emoji, string title, string value) + { + AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); + } + + public void ShowCompletion(string title) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); + } + + public void ClearPreviousLine() + { + AnsiConsole.Cursor.MoveUp(); + System.Console.WriteLine(' '.Repeat(System.Console.WindowWidth)); + AnsiConsole.Cursor.MoveUp(); + } + + public bool PromptForConfirmation(string question) + { + return AnsiConsole.Confirm(question); + } + + public string PromptForInput(string question, string defaultValue = null) + { + return AnsiConsole.Prompt( + new TextPrompt(question) + .DefaultValue(defaultValue)); + } + + public string PromptForSecret(string title, int? minLength = null) + { + Assert.False(title.EndsWith(':')); + + return AnsiConsole.Prompt( + new TextPrompt($"{title}:") + .Secret() + .Validate(x => minLength == null || x.Length >= minLength, + message: $"Secret must be at least {minLength} characters long")); + } + + public T PromptForChoice(string question, params (T Value, string Description)[] choices) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(question) + .HighlightStyle(new Style(Color.Turquoise2)) + .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description) + .AddChoices(choices.Select(x => x.Value))); + return choice; + } + + public void ConfirmExecution(string title, Action action) + { + Assert.False(title.EndsWith('?')); + + var confirmation = PromptForConfirmation($"{title}?"); + ClearPreviousLine(); + + if (confirmation) + { + AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); + try + { + action.Invoke(); + } + catch (Exception) + { + confirmation = false; + title = $"{title} (failed)"; + } + finally + { + ClearPreviousLine(); + } + } + + var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); + AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); + } +} diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs index 2f2cdb624..6c7c06e66 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs @@ -24,12 +24,14 @@ public class GitHubActionsCheckoutStep : GitHubActionsStep /// public string Ref { get; set; } + public string[] CheckoutWith { get; set; } = new string[0]; + public override void Write(CustomFileWriter writer) { writer.WriteLine("- uses: actions/checkout@v6"); if (Submodules.HasValue || Lfs.HasValue || FetchDepth.HasValue || Progress.HasValue || - !Filter.IsNullOrWhiteSpace() || !Ref.IsNullOrWhiteSpace()) + !Filter.IsNullOrWhiteSpace() || !Ref.IsNullOrWhiteSpace() || CheckoutWith.Length > 0) { using (writer.Indent()) { @@ -57,6 +59,9 @@ public override void Write(CustomFileWriter writer) writer.WriteLine("repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}"); writer.WriteLine($"ref: {Ref}"); } + + foreach (var line in CheckoutWith) + writer.WriteLine(line); } } } diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs index 7c336a6f7..4af60abbd 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs @@ -15,6 +15,7 @@ public class GitHubActionsConfiguration : ConfigurationEntity public (GitHubActionsPermissions Type, string Permission)[] Permissions { get; set; } public string ConcurrencyGroup { get; set; } public bool ConcurrencyCancelInProgress { get; set; } + public string DefaultShell { get; set; } public GitHubActionsJob[] Jobs { get; set; } public override void Write(CustomFileWriter writer) @@ -75,6 +76,21 @@ public override void Write(CustomFileWriter writer) } } + if (!DefaultShell.IsNullOrWhiteSpace()) + { + writer.WriteLine(); + // defaults.run currently carries only shell; further run defaults (e.g. working-directory) slot in here + writer.WriteLine("defaults:"); + using (writer.Indent()) + { + writer.WriteLine("run:"); + using (writer.Indent()) + { + writer.WriteLine($"shell: {DefaultShell}"); + } + } + } + writer.WriteLine(); writer.WriteLine("jobs:"); diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 54bf1f241..14d961302 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -91,6 +91,15 @@ public GitHubActionsAttribute( public string JobConcurrencyGroup { get; set; } public bool JobConcurrencyCancelInProgress { get; set; } + /// + /// Pins the shell for every run: step via a workflow-level defaults.run.shell block, + /// so cross-platform matrix jobs use one consistent shell instead of the per-OS default (bash + /// on Linux/macOS, pwsh on Windows). Accepts any value GitHub allows — a built-in (bash, + /// pwsh, sh, cmd, powershell, python) or a custom command {0} + /// template. Unset or whitespace-only emits no defaults: block. + /// + public string DefaultShell { get; set; } + public string[] InvokedTargets { get; set; } = new string[0]; /// @@ -149,6 +158,19 @@ public string CheckoutRef get => throw new NotSupportedException(); } + /// + /// Extra actions/checkout inputs, emitted verbatim inside the step's with: block after + /// the typed keys (submodules, lfs, fetch-depth, progress, filter, + /// ref/repository). An escape hatch for inputs the typed knobs don't cover — token, + /// ssh-key, path, clean, persist-credentials, sparse-checkout, + /// set-safe-directory. + /// + /// Each entry is one raw line — passed through unvalidated, so the caller owns correct YAML. Multi-line + /// block scalars work by supplying the key (e.g. sparse-checkout: |) and each continuation line + /// as separate entries, with the caller's own indentation preserved. Empty emits nothing. + /// + public string[] CheckoutWith { get; set; } = new string[0]; + public override CustomFileWriter CreateWriter(StreamWriter streamWriter) { return new CustomFileWriter(streamWriter, indentationFactor: 2, commentPrefix: "#"); @@ -179,6 +201,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection (x, "read"))).ToArray(), ConcurrencyGroup = ConcurrencyGroup, ConcurrencyCancelInProgress = ConcurrencyCancelInProgress, + DefaultShell = DefaultShell, Jobs = _images.Select(x => GetJobs(x, relevantTargets)).ToArray() }; @@ -219,7 +242,8 @@ private IEnumerable GetSteps(IReadOnlyCollection dispatcher.DispatchAsync([":bogus"], SomeRoot, SomeScript); + + (await action.Should().ThrowAsync() + .WithMessage("*'bogus' is not supported*")) + .And.Message.Should().ContainAll("- run", "- setup"); + } + + [Fact] + public async Task Dispatch_EmptyCommandToken_Fails() + { + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { new RecordingCommand("run") }, new FakePrompts()); + + var action = () => dispatcher.DispatchAsync([":"], SomeRoot, SomeScript); + + await action.Should().ThrowAsync().WithMessage("*No command specified*"); + } + + [Fact] + public async Task Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: true)); + + await dispatcher.DispatchAsync(["whatever"], rootDirectory: null, buildScript: null); + + setup.WasCalled.Should().BeTrue(); + setup.ReceivedArgs.Should().BeEmpty(); + setup.ReceivedRoot.Should().BeNull(); + } + + [Fact] + public async Task Dispatch_NoCommand_NullRoot_Declined_ReturnsZeroWithoutSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: false)); + + var exitCode = await dispatcher.DispatchAsync(["whatever"], rootDirectory: null, buildScript: null); + + exitCode.Should().Be(0); + setup.WasCalled.Should().BeFalse(); + } + + [Fact] + public async Task Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() + { + using var root = TempRoot.Create(); + var buildProject = root.WriteBuildProjectAtConvention(); + var run = new RecordingCommand("run"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { run, new RecordingCommand("setup") }, + new FakePrompts()); + + await dispatcher.DispatchAsync(["compile", "--verbose"], root.Path, buildScript: null); + + run.WasCalled.Should().BeTrue(); + run.ReceivedArgs.Should().Equal("compile", "--verbose"); + run.ReceivedBuildScript.Should().Be(buildProject); + } + + private sealed class RecordingCommand : IFalloutCommand + { + private readonly int exitCode; + + public RecordingCommand(string name, int exitCode = 0) + { + Name = name; + this.exitCode = exitCode; + } + + public string Name { get; } + public bool WasCalled { get; private set; } + public string[] ReceivedArgs { get; private set; } + public AbsolutePath ReceivedRoot { get; private set; } + public AbsolutePath ReceivedBuildScript { get; private set; } + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + WasCalled = true; + ReceivedArgs = args; + ReceivedRoot = rootDirectory; + ReceivedBuildScript = buildScript; + return Task.FromResult(exitCode); + } + } + + private sealed class FakePrompts : IConsolePrompts + { + private readonly bool confirm; + + public FakePrompts(bool confirm = false) => this.confirm = confirm; + + public bool PromptForConfirmation(string question) => confirm; + + public void ShowInput(string emoji, string title, string value) { } + public void ShowCompletion(string title) { } + public void ClearPreviousLine() { } + public string PromptForInput(string question, string defaultValue = null) => defaultValue; + public string PromptForSecret(string title, int? minLength = null) => string.Empty; + public T PromptForChoice(string question, params (T Value, string Description)[] choices) => default; + public void ConfirmExecution(string title, Action action) => action(); + } + + private sealed class TempRoot : IDisposable + { + public AbsolutePath Path { get; } + + private TempRoot(AbsolutePath path) + { + Path = path; + (path / ".fallout").CreateDirectory(); + } + + public static TempRoot Create() + { + var dir = (AbsolutePath)System.IO.Path.Combine(System.IO.Path.GetTempPath(), "fallout-dispatch-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + return new TempRoot(dir); + } + + public AbsolutePath WriteBuildProjectAtConvention() + { + var buildDir = Path / "build"; + buildDir.CreateDirectory(); + var projectFile = buildDir / "_build.csproj"; + File.WriteAllText(projectFile, string.Empty); + return projectFile; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..0cafe9aa3 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,58 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + path: src + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..f6d1be72b --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + src + build + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..1a694dd61 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,61 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + path: src + persist-credentials: false + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..6b8e51885 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,68 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +permissions: + contents: write + actions: read + +concurrency: + group: ${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: pwsh + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..858b288bf --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +defaults: + run: + shell: pwsh + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs index 0c11339a5..f1ce14ccc 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -212,6 +212,55 @@ public class TestBuild : FalloutBuild } ); + // Ordering guard: extra CheckoutWith inputs emit verbatim inside the with: block, after + // every typed key (here fetch-depth) and in the order supplied. + yield return + ( + "checkout-with", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + FetchDepth = 0, + CheckoutWith = new[] + { + "token: ${{ secrets.CI_PAT }}", + "path: src", + "persist-credentials: false" + } + } + ); + + // Guard: CheckoutWith with no typed checkout key must still open the with: block. + yield return + ( + "checkout-with-only", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + CheckoutWith = new[] { "path: src" } + } + ); + + // Multi-line block scalar: the case a 'KEY: value' validator would reject. Proves raw + // verbatim emission preserves the caller-supplied continuation lines and indentation. + yield return + ( + "checkout-with-sparse", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + CheckoutWith = new[] + { + "sparse-checkout: |", + " src", + " build" + } + } + ); + yield return ( "runs-on-labels", @@ -223,6 +272,33 @@ public class TestBuild : FalloutBuild } ); + yield return + ( + "default-shell", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + DefaultShell = "pwsh" + } + ); + + // Ordering guard: with DefaultShell, permissions, and concurrency all set, the defaults: + // block must be emitted after concurrency: and before jobs:, with correct blank lines. + yield return + ( + "default-shell-with-permissions", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + DefaultShell = "pwsh", + WritePermissions = new[] { GitHubActionsPermissions.Contents }, + ReadPermissions = new[] { GitHubActionsPermissions.Actions }, + ConcurrencyCancelInProgress = true + } + ); + yield return ( null,