From 9cd6df7a11b2d08d5516cfb90ca736876ec7f80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Fri, 26 Jun 2026 15:01:35 +0200 Subject: [PATCH 1/8] Add GitHubActionsInputType enum and [GitHubActionsInput] attribute Typed, compile-checked declaration surface for workflow_dispatch inputs (string/boolean/number/choice/environment), repeatable on the build class and scoped by workflow. Pure data carriers; not yet consumed. --- .../GitHubActionsInputAttribute.cs | 34 +++++++++++++++++++ .../GitHubActions/GitHubActionsInputType.cs | 16 +++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs create mode 100644 src/Fallout.Common/CI/GitHubActions/GitHubActionsInputType.cs diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs new file mode 100644 index 00000000..e22b4644 --- /dev/null +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs @@ -0,0 +1,34 @@ +using System; + +namespace Fallout.Common.CI.GitHubActions; + +/// +/// Declares a typed workflow_dispatch input. Repeatable on the build class and collected by the +/// generator — the typed, compile-checked replacement for the +/// OnWorkflowDispatch*Inputs string arrays. Emits type:/default:/options: +/// into each matching workflow's workflow_dispatch: trigger. +/// +/// Scope with : empty applies the input to every dispatch workflow on the class; +/// otherwise only to the named ones. Misconfiguration (e.g. +/// without , a default outside the options, an unknown workflow name, a blank name) +/// fails generation loudly rather than emitting broken YAML. +/// +/// is emitted verbatim — for free-form +/// and inputs the caller owns YAML-correct quoting. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class GitHubActionsInputAttribute : Attribute +{ + public GitHubActionsInputAttribute(string name) + { + Name = name; + } + + public string Name { get; } + public GitHubActionsInputType Type { get; set; } = GitHubActionsInputType.String; + public bool Required { get; set; } + public string Default { get; set; } + public string[] Options { get; set; } = new string[0]; + public string Description { get; set; } + public string[] Workflows { get; set; } = new string[0]; +} diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputType.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputType.cs new file mode 100644 index 00000000..7deeacb3 --- /dev/null +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputType.cs @@ -0,0 +1,16 @@ +using Fallout.Common.Tooling; + +namespace Fallout.Common.CI.GitHubActions; + +/// +/// The type of a workflow_dispatch input. See +/// workflow_dispatch.inputs. +/// +public enum GitHubActionsInputType +{ + [EnumValue("string")] String, + [EnumValue("boolean")] Boolean, + [EnumValue("number")] Number, + [EnumValue("choice")] Choice, + [EnumValue("environment")] Environment +} From 9c820a364f6b98b4ce22f043a3415473fa81d134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Fri, 26 Jun 2026 15:01:43 +0200 Subject: [PATCH 2/8] Emit typed workflow_dispatch inputs from the GitHub Actions generator Collect [GitHubActionsInput] off the build class, scope by workflow name, and fold the legacy OnWorkflowDispatch*Inputs arrays into one model. The trigger writer emits type:/default:/options: (string omits type:, keeping existing output byte-identical). Misconfiguration fails generation loudly via ArgumentException (choice without options, default outside options/non-numeric/ non-boolean, unknown workflow name, options on a non-choice, duplicate or blank names). The legacy arrays are marked [Obsolete] (removal 2027.x.x). --- .../GitHubActionsWorkflowDispatchInput.cs | 11 +++ .../GitHubActionsWorkflowDispatchTrigger.cs | 57 +++++++++--- .../GitHubActions/GitHubActionsAttribute.cs | 93 +++++++++++++++++-- 3 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchInput.cs diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchInput.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchInput.cs new file mode 100644 index 00000000..573351f6 --- /dev/null +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchInput.cs @@ -0,0 +1,11 @@ +namespace Fallout.Common.CI.GitHubActions.Configuration; + +public class GitHubActionsWorkflowDispatchInput +{ + public string Name { get; set; } + public GitHubActionsInputType Type { get; set; } + public bool Required { get; set; } + public string Default { get; set; } + public string[] Options { get; set; } = new string[0]; + public string Description { get; set; } +} diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs index bf4b7749..28b11399 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs @@ -1,5 +1,6 @@ -using System; -using System.Linq; +using System; +using System.Collections.Generic; +using Fallout.Common.Tooling; using Fallout.Common.Utilities; using Fallout.Common.Utilities.Collections; @@ -7,8 +8,13 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; public class GitHubActionsWorkflowDispatchTrigger : GitHubActionsDetailedTrigger { - public string[] OptionalInputs { get; set; } - public string[] RequiredInputs { get; set; } + [Obsolete($"Set {nameof(Inputs)} instead. Removed in 2027.x.x.")] + public string[] OptionalInputs { get; set; } = new string[0]; + + [Obsolete($"Set {nameof(Inputs)} instead. Removed in 2027.x.x.")] + public string[] RequiredInputs { get; set; } = new string[0]; + + public GitHubActionsWorkflowDispatchInput[] Inputs { get; set; } = new GitHubActionsWorkflowDispatchInput[0]; public override void Write(CustomFileWriter writer) { @@ -18,19 +24,44 @@ public override void Write(CustomFileWriter writer) writer.WriteLine("inputs:"); using (writer.Indent()) { - void WriteInput(string input, bool required) + GetInputs().ForEach(WriteInput); + } + } + + void WriteInput(GitHubActionsWorkflowDispatchInput input) + { + writer.WriteLine($"{input.Name}:"); + using (writer.Indent()) + { + var description = input.Description ?? input.Name.SplitCamelHumpsWithKnownWords().JoinSpace(); + writer.WriteLine($"description: {description.DoubleQuote()}"); + writer.WriteLine($"required: {input.Required.ToString().ToLowerInvariant()}"); + if (input.Type != GitHubActionsInputType.String) + writer.WriteLine($"type: {input.Type.GetValue()}"); + if (input.Default != null) + writer.WriteLine($"default: {input.Default}"); + if (input.Type == GitHubActionsInputType.Choice) { - writer.WriteLine($"{input}:"); + writer.WriteLine("options:"); using (writer.Indent()) - { - writer.WriteLine($"description: {input.SplitCamelHumpsWithKnownWords().JoinSpace().DoubleQuote()}"); - writer.WriteLine($"required: {required.ToString().ToLowerInvariant()}"); - } + input.Options.ForEach(x => writer.WriteLine($"- {x.DoubleQuote()}")); } - - OptionalInputs.ForEach(x => WriteInput(x, required: false)); - RequiredInputs.ForEach(x => WriteInput(x, required: true)); } } } + + // Legacy OptionalInputs/RequiredInputs emit first as untyped string inputs, preserving existing + // output; typed Inputs follow. Direct consumers of the obsolete arrays keep working. + private IEnumerable GetInputs() + { +#pragma warning disable CS0618 // deliberate bridge for the obsolete legacy arrays + foreach (var input in OptionalInputs) + yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = false }; + foreach (var input in RequiredInputs) + yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = true }; +#pragma warning restore CS0618 + + foreach (var input in Inputs) + yield return input; + } } diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 14d96130..5ca5de8f 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; using Fallout.Common.CI.GitHubActions.Configuration; using Fallout.Common.Execution; using Fallout.Common.IO; @@ -54,7 +56,9 @@ public GitHubActionsAttribute( public string[] OnPullRequestTags { get; set; } = new string[0]; public string[] OnPullRequestIncludePaths { get; set; } = new string[0]; public string[] OnPullRequestExcludePaths { get; set; } = new string[0]; + [Obsolete($"Use [{nameof(GitHubActionsInputAttribute)}] instead. Removed in 2027.x.x.")] public string[] OnWorkflowDispatchOptionalInputs { get; set; } = new string[0]; + [Obsolete($"Use [{nameof(GitHubActionsInputAttribute)}] instead. Removed in 2027.x.x.")] public string[] OnWorkflowDispatchRequiredInputs { get; set; } = new string[0]; public string OnCronSchedule { get; set; } @@ -191,6 +195,8 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection GetSteps(IReadOnlyCollection GetImports() { - foreach (var input in OnWorkflowDispatchOptionalInputs.Concat(OnWorkflowDispatchRequiredInputs)) - yield return (input, $"${{{{ github.event.inputs.{input} }}}}"); + foreach (var input in GetWorkflowDispatchInputs()) + yield return (input.Name, $"${{{{ github.event.inputs.{input.Name} }}}}"); static string GetSecretValue(string secret) => $"${{{{ secrets.{secret.SplitCamelHumpsWithKnownWords().JoinUnderscore().ToUpperInvariant()} }}}}"; @@ -340,17 +346,84 @@ protected virtual IEnumerable GetTriggers() }; } - if (OnWorkflowDispatchOptionalInputs.Length > 0 || - OnWorkflowDispatchRequiredInputs.Length > 0) - { - yield return new GitHubActionsWorkflowDispatchTrigger + var dispatchInputs = GetWorkflowDispatchInputs().ToArray(); + if (dispatchInputs.Length > 0) + yield return new GitHubActionsWorkflowDispatchTrigger { Inputs = dispatchInputs }; + + if (OnCronSchedule != null) + yield return new GitHubActionsScheduledTrigger { Cron = OnCronSchedule }; + } + + // The typed [GitHubActionsInput] attributes declared on the build class. Overridable so tests can + // inject inputs without static class-level attributes (mirrors the GetJobs/GetImports/GetTriggers seams). + protected virtual IEnumerable DeclaredInputs + => Build.GetType().GetCustomAttributes(); + + // The names of every [GitHubActions] workflow declared on the build class — the valid targets for an + // input's Workflows scope. The current workflow is always among them in production. + protected virtual ISet DeclaredWorkflowNames + => Build.GetType().GetCustomAttributes().Select(x => x.IdPostfix).ToHashSet(); + + private IEnumerable GetWorkflowDispatchInputs() + { + // legacy arrays first → untyped string inputs, preserving the existing ordering and output +#pragma warning disable CS0618 // deliberate bridge for the obsolete legacy arrays + foreach (var input in OnWorkflowDispatchOptionalInputs) + yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = false }; + foreach (var input in OnWorkflowDispatchRequiredInputs) + yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = true }; +#pragma warning restore CS0618 + + foreach (var input in DeclaredInputs.Where(x => x.Workflows.Length == 0 || x.Workflows.Contains(_name))) + yield return new GitHubActionsWorkflowDispatchInput { - OptionalInputs = OnWorkflowDispatchOptionalInputs, - RequiredInputs = OnWorkflowDispatchRequiredInputs + Name = input.Name, + Type = input.Type, + Required = input.Required, + Default = input.Default, + Options = input.Options, + Description = input.Description }; + } + + private void ValidateWorkflowDispatchInputs() + { + var declaredWorkflows = DeclaredWorkflowNames; + + foreach (var input in DeclaredInputs) + { + if (input.Type == GitHubActionsInputType.Choice) + Assert.True(input.Options.Length > 0, + $"'{input.Name}' is a choice input and requires non-empty '{nameof(GitHubActionsInputAttribute.Options)}'"); + else + Assert.True(input.Options.Length == 0, + $"'{input.Name}' sets '{nameof(GitHubActionsInputAttribute.Options)}' but its type is not '{nameof(GitHubActionsInputType.Choice)}'"); + + if (input.Default != null) + { + if (input.Type == GitHubActionsInputType.Choice) + Assert.True(input.Options.Contains(input.Default), + $"'{input.Name}' default '{input.Default}' is not one of its options"); + if (input.Type == GitHubActionsInputType.Number) + Assert.True(double.TryParse(input.Default, NumberStyles.Any, CultureInfo.InvariantCulture, out _), + $"'{input.Name}' default '{input.Default}' is not a valid number"); + if (input.Type == GitHubActionsInputType.Boolean) + Assert.True(input.Default is "true" or "false", + $"'{input.Name}' default '{input.Default}' must be 'true' or 'false'"); + } + + foreach (var workflow in input.Workflows) + Assert.True(declaredWorkflows.Contains(workflow), + $"'{input.Name}' targets unknown workflow '{workflow}'"); } - if (OnCronSchedule != null) - yield return new GitHubActionsScheduledTrigger { Cron = OnCronSchedule }; + var inputs = GetWorkflowDispatchInputs().ToList(); + foreach (var input in inputs) + Assert.True(!input.Name.IsNullOrWhiteSpace(), + $"workflow_dispatch input names must be non-empty in workflow '{_name}'"); + + var names = inputs.Select(x => x.Name).ToList(); + Assert.True(names.Count == names.Distinct().Count(), + $"Duplicate workflow_dispatch input names in workflow '{_name}'"); } } From 21e01aff03684941529eb177ec820d67cd18653b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Fri, 26 Jun 2026 15:02:00 +0200 Subject: [PATCH 3/8] Add validation tests for typed workflow_dispatch inputs Covers every loud check: choice without options, default outside options, options on a non-choice, non-numeric/non-boolean defaults, unknown workflow name, duplicate names, and blank names. --- .../CI/GitHubActionsInputValidationSpecs.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs new file mode 100644 index 00000000..d53a522b --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs @@ -0,0 +1,85 @@ +using System; +using Fallout.Common.CI; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.Execution; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Specs.CI; + +public class GitHubActionsInputValidationSpecs +{ + [Theory] + [InlineData(GitHubActionsInputType.Choice, null, new string[0])] // choice without options + [InlineData(GitHubActionsInputType.Choice, "x", new[] { "a", "b" })] // default not in options + [InlineData(GitHubActionsInputType.String, null, new[] { "a" })] // options on a non-choice input + [InlineData(GitHubActionsInputType.Number, "abc", new string[0])] // non-numeric number default + [InlineData(GitHubActionsInputType.Boolean, "yes", new string[0])] // non-boolean boolean default + public void Malformed_input_throws(GitHubActionsInputType type, string @default, string[] options) + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") { Type = type, Default = @default, Options = options }); + + act.Should().Throw(); + } + + [Theory] + [InlineData(GitHubActionsInputType.Choice, "a", new[] { "a", "b" })] + [InlineData(GitHubActionsInputType.Number, "1.5", new string[0])] + [InlineData(GitHubActionsInputType.Boolean, "true", new string[0])] + [InlineData(GitHubActionsInputType.Environment, null, new string[0])] + [InlineData(GitHubActionsInputType.String, null, new string[0])] + public void Well_formed_input_does_not_throw(GitHubActionsInputType type, string @default, string[] options) + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") { Type = type, Default = @default, Options = options }); + + act.Should().NotThrow(); + } + + [Fact] + public void Unknown_workflow_name_throws() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") { Workflows = new[] { "does-not-exist" } }); + + act.Should().Throw(); + } + + [Fact] + public void Duplicate_input_name_throws() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Dup"), + new GitHubActionsInputAttribute("Dup")); + + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Blank_input_name_throws(string name) + { + var act = () => GetConfiguration(new GitHubActionsInputAttribute(name)); + + act.Should().Throw(); + } + + private static void GetConfiguration(params GitHubActionsInputAttribute[] inputs) + { + var build = new ConfigurationGenerationSpecs.TestBuild(); + var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile); + + // No shorthand On: the typed inputs alone drive the detailed workflow_dispatch trigger, so + // ShortTriggers stays empty and the short-XOR-detailed assert does not interfere. + var attribute = new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + Inputs = inputs + }; + ((ConfigurationAttributeBase)attribute).Build = build; + + attribute.GetConfiguration(relevantTargets); + } +} From 51d7add2fa01107d48e14b3dff9b2a732765b009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Fri, 26 Jun 2026 15:02:00 +0200 Subject: [PATCH 4/8] Add snapshot coverage for typed workflow_dispatch inputs Three Verify cases: every input kind (dispatch-typed-inputs), Workflows scoping (dispatch-input-scoping), and legacy + typed coexistence (dispatch-legacy-plus-typed). TestGitHubActionsAttribute gains a named-workflow ctor and Inputs/WorkflowNames seams so cases vary inputs without static class-level attributes. --- ...ribute=GitHubActionsAttribute.verified.txt | 63 +++++++++++++ ...ribute=GitHubActionsAttribute.verified.txt | 73 +++++++++++++++ ...ribute=GitHubActionsAttribute.verified.txt | 90 +++++++++++++++++++ .../CI/ConfigurationGenerationSpecs.cs | 58 ++++++++++++ .../CI/SpecGitHubActionsAttribute.cs | 16 ++++ 5 files changed, 300 insertions(+) create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 00000000..966be79d --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,63 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_publish --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: publish + +on: + workflow_dispatch: + inputs: + ForPublishOnly: + description: "For Publish Only" + required: false + +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 + env: + ForPublishOnly: ${{ github.event.inputs.ForPublishOnly }} + - 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=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 00000000..c9ff4453 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,73 @@ +# ------------------------------------------------------------------------------ +# +# +# 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: + workflow_dispatch: + inputs: + LegacyOptional: + description: "Legacy Optional" + required: false + LegacyRequired: + description: "Legacy Required" + required: true + TypedFlag: + description: "Typed Flag" + required: false + type: boolean + default: true + +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 + env: + LegacyOptional: ${{ github.event.inputs.LegacyOptional }} + LegacyRequired: ${{ github.event.inputs.LegacyRequired }} + TypedFlag: ${{ github.event.inputs.TypedFlag }} + - 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=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 00000000..8b4c79c1 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,90 @@ +# ------------------------------------------------------------------------------ +# +# +# 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: + workflow_dispatch: + inputs: + Plain: + description: "Plain" + required: false + Verbose: + description: "Verbose" + required: false + type: boolean + default: false + Retries: + description: "Retries" + required: false + type: number + default: 3 + Channel: + description: "Channel" + required: false + type: choice + default: beta + options: + - "alpha" + - "beta" + - "stable" + Target: + description: "Target" + required: false + type: environment + +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 + env: + Plain: ${{ github.event.inputs.Plain }} + Verbose: ${{ github.event.inputs.Verbose }} + Retries: ${{ github.event.inputs.Retries }} + Channel: ${{ github.event.inputs.Channel }} + Target: ${{ github.event.inputs.Target }} + - 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 f1ce14cc..f2e9c4a4 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -146,8 +146,10 @@ public class TestBuild : FalloutBuild OnPullRequestTags = new[] { "pull_request_tag" }, OnPullRequestIncludePaths = new[] { "pull_request_include_path" }, OnPullRequestExcludePaths = new[] { "pull_request_exclude_path/**" }, +#pragma warning disable CS0618 // regression guard: the obsolete legacy path must still emit correctly OnWorkflowDispatchOptionalInputs = new[] { "OptionalInput" }, OnWorkflowDispatchRequiredInputs = new[] { "RequiredInput" }, +#pragma warning restore CS0618 PublishCondition = "success() || failure()", Submodules = GitHubActionsSubmodules.Recursive, Lfs = true, @@ -261,6 +263,62 @@ public class TestBuild : FalloutBuild } ); + // Guard: every typed input kind emits its type:/default:/options: correctly; a string input + // omits type: so untyped output stays byte-identical. The typed inputs alone drive the + // detailed workflow_dispatch trigger (no shorthand On). + yield return + ( + "dispatch-typed-inputs", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(Test) }, + Inputs = new[] + { + new GitHubActionsInputAttribute("Plain"), + new GitHubActionsInputAttribute("Verbose") { Type = GitHubActionsInputType.Boolean, Default = "false" }, + new GitHubActionsInputAttribute("Retries") { Type = GitHubActionsInputType.Number, Default = "3" }, + new GitHubActionsInputAttribute("Channel") { Type = GitHubActionsInputType.Choice, Options = new[] { "alpha", "beta", "stable" }, Default = "beta" }, + new GitHubActionsInputAttribute("Target") { Type = GitHubActionsInputType.Environment } + } + } + ); + + // Ordering guard: a Workflows-scoped input appears only in the named workflow; one scoped to + // a different workflow is filtered out for this one. + yield return + ( + "dispatch-input-scoping", + new TestGitHubActionsAttribute("publish", GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(Test) }, + WorkflowNames = new[] { "publish", "build" }, + Inputs = new[] + { + new GitHubActionsInputAttribute("ForPublishOnly") { Workflows = new[] { "publish" } }, + new GitHubActionsInputAttribute("ForOtherWorkflow") { Workflows = new[] { "build" } } + } + } + ); + + // Regression guard: legacy arrays and typed inputs coexist; legacy emit first and untyped, + // typed follow. + yield return + ( + "dispatch-legacy-plus-typed", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(Test) }, +#pragma warning disable CS0618 // regression guard: the obsolete legacy path must still emit correctly + OnWorkflowDispatchOptionalInputs = new[] { "LegacyOptional" }, + OnWorkflowDispatchRequiredInputs = new[] { "LegacyRequired" }, +#pragma warning restore CS0618 + Inputs = new[] + { + new GitHubActionsInputAttribute("TypedFlag") { Type = GitHubActionsInputType.Boolean, Default = "true" } + } + } + ); + yield return ( "runs-on-labels", diff --git a/tests/Fallout.Common.Specs/CI/SpecGitHubActionsAttribute.cs b/tests/Fallout.Common.Specs/CI/SpecGitHubActionsAttribute.cs index 3f8c633b..ddf2f05e 100644 --- a/tests/Fallout.Common.Specs/CI/SpecGitHubActionsAttribute.cs +++ b/tests/Fallout.Common.Specs/CI/SpecGitHubActionsAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Fallout.Common.CI.GitHubActions; @@ -12,8 +13,23 @@ public TestGitHubActionsAttribute(GitHubActionsImage image, params GitHubActions { } + public TestGitHubActionsAttribute(string name, GitHubActionsImage image, params GitHubActionsImage[] images) + : base(name, image, images) + { + } + public StreamWriter Stream { get; set; } + // Injects typed inputs without static class-level attributes, so each test case can vary them. + public GitHubActionsInputAttribute[] Inputs { get; set; } = new GitHubActionsInputAttribute[0]; + + // The workflow names an input may scope to; defaults to just this workflow when unset. + public string[] WorkflowNames { get; set; } + + protected override IEnumerable DeclaredInputs => Inputs; + + protected override ISet DeclaredWorkflowNames => (WorkflowNames ?? new[] { IdPostfix }).ToHashSet(); + protected override StreamWriter CreateStream() { return Stream; From 97bd41bbcd05fd83da2863a11ab3029f85117add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Mon, 29 Jun 2026 08:51:47 +0200 Subject: [PATCH 5/8] Normalize workflow_dispatch input scoping for spaced workflow names Workflow names are spaces-to-underscores normalized in the ctor, so an input's Workflows scope must be normalized the same way. Without this, scoping to a workflow declared with a space (e.g. "My Workflow") silently dropped the input and validation threw "unknown workflow" for a correctly-spelled name. --- .../CI/GitHubActions/GitHubActionsAttribute.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 5ca5de8f..a74eebb0 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -33,7 +33,7 @@ public GitHubActionsAttribute( GitHubActionsImage image, params GitHubActionsImage[] images) { - _name = name.Replace(oldChar: ' ', newChar: '_'); + _name = NormalizeWorkflowName(name); _images = new[] { image }.Concat(images).ToArray(); } @@ -354,6 +354,10 @@ protected virtual IEnumerable GetTriggers() yield return new GitHubActionsScheduledTrigger { Cron = OnCronSchedule }; } + // Workflow names are spaces-to-underscores normalized (see the ctor), so an input's Workflows scope + // must be normalized the same way to match the workflow it names. + private static string NormalizeWorkflowName(string name) => name.Replace(oldChar: ' ', newChar: '_'); + // The typed [GitHubActionsInput] attributes declared on the build class. Overridable so tests can // inject inputs without static class-level attributes (mirrors the GetJobs/GetImports/GetTriggers seams). protected virtual IEnumerable DeclaredInputs @@ -374,7 +378,8 @@ private IEnumerable GetWorkflowDispatchInput yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = true }; #pragma warning restore CS0618 - foreach (var input in DeclaredInputs.Where(x => x.Workflows.Length == 0 || x.Workflows.Contains(_name))) + foreach (var input in DeclaredInputs.Where(x => x.Workflows.Length == 0 || + x.Workflows.Select(NormalizeWorkflowName).Contains(_name))) yield return new GitHubActionsWorkflowDispatchInput { Name = input.Name, @@ -388,7 +393,7 @@ private IEnumerable GetWorkflowDispatchInput private void ValidateWorkflowDispatchInputs() { - var declaredWorkflows = DeclaredWorkflowNames; + var declaredWorkflows = DeclaredWorkflowNames.Select(NormalizeWorkflowName).ToHashSet(); foreach (var input in DeclaredInputs) { @@ -413,7 +418,7 @@ private void ValidateWorkflowDispatchInputs() } foreach (var workflow in input.Workflows) - Assert.True(declaredWorkflows.Contains(workflow), + Assert.True(declaredWorkflows.Contains(NormalizeWorkflowName(workflow)), $"'{input.Name}' targets unknown workflow '{workflow}'"); } From d65f56fdf67b39be54bad99dfc485b880e706947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Mon, 29 Jun 2026 08:51:48 +0200 Subject: [PATCH 6/8] Cover obsolete and new workflow_dispatch input APIs Direct trigger-layer tests for the restored obsolete OptionalInputs/RequiredInputs arrays (the attribute path only ever sets Inputs), plus equivalence guards proving the obsolete and new APIs emit byte-identical YAML, and a regression test for spaced-workflow scoping. --- .../CI/GitHubActionsDispatchInputSpecs.cs | 74 ++++++++++++++++ ...tHubActionsWorkflowDispatchTriggerSpecs.cs | 88 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs create mode 100644 tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs new file mode 100644 index 00000000..21574382 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs @@ -0,0 +1,74 @@ +using System.IO; +using Fallout.Common.CI; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.Execution; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Specs.CI; + +// End-to-end behaviour of workflow_dispatch inputs through the attribute → generator → YAML pipeline, +// across the obsolete and new APIs. +public class GitHubActionsDispatchInputSpecs +{ + private static string Render(TestGitHubActionsAttribute attribute) + { + var build = new ConfigurationGenerationSpecs.TestBuild(); + var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile); + + var stream = new MemoryStream(); + ((ConfigurationAttributeBase)attribute).Build = build; + attribute.Stream = new StreamWriter(stream, leaveOpen: true); + attribute.Generate(relevantTargets); + + stream.Seek(offset: 0, SeekOrigin.Begin); + return new StreamReader(stream).ReadToEnd(); + } + + // Regression guard: the obsolete OnWorkflowDispatch*Inputs arrays and equivalent typed String inputs + // generate byte-identical YAML — migrating off the obsolete API is a no-op on the output. + [Fact] + public void Legacy_arrays_and_typed_string_inputs_emit_identical_yaml() + { +#pragma warning disable CS0618 // comparing the obsolete API against its typed replacement on purpose + var legacy = Render(new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + OnWorkflowDispatchOptionalInputs = new[] { "Opt" }, + OnWorkflowDispatchRequiredInputs = new[] { "Req" } + }); +#pragma warning restore CS0618 + + var typed = Render(new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + Inputs = new[] + { + new GitHubActionsInputAttribute("Opt"), + new GitHubActionsInputAttribute("Req") { Required = true } + } + }); + + typed.Should().Be(legacy); + } + + // Regression guard: a workflow name with spaces is normalized to underscores; an input scoped to the + // same spelled name must still resolve (not silently drop, not throw "unknown workflow"). + [Fact] + public void Input_scoped_to_a_spaced_workflow_name_is_included() + { + var attribute = new TestGitHubActionsAttribute("My Workflow", GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + Inputs = new[] + { + new GitHubActionsInputAttribute("Scoped") { Workflows = new[] { "My Workflow" } } + } + }; + + var render = () => Render(attribute); + + render.Should().NotThrow(); + render().Should().Contain("Scoped:"); + } +} diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs new file mode 100644 index 00000000..b6cf6560 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.CI.GitHubActions.Configuration; +using Fallout.Common.Utilities; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Specs.CI; + +// Exercises the trigger's public API directly — the obsolete OptionalInputs/RequiredInputs arrays that +// a consumer overriding GetTriggers() can still set, which the attribute path never populates. Guards +// the backward-compatible legacy fold in GitHubActionsWorkflowDispatchTrigger.GetInputs(). +public class GitHubActionsWorkflowDispatchTriggerSpecs +{ + private static string Render(GitHubActionsWorkflowDispatchTrigger trigger) + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream, leaveOpen: true); + trigger.Write(new CustomFileWriter(writer, indentationFactor: 2, commentPrefix: "#")); + writer.Flush(); + + stream.Seek(offset: 0, SeekOrigin.Begin); + return new StreamReader(stream).ReadToEnd(); + } + + // Guard: the obsolete legacy arrays still emit, untyped, with the correct required flags. + [Fact] + public void Legacy_arrays_emit_untyped_inputs() + { +#pragma warning disable CS0618 // exercising the restored obsolete trigger API on purpose + var yaml = Render(new GitHubActionsWorkflowDispatchTrigger + { + OptionalInputs = new[] { "Opt" }, + RequiredInputs = new[] { "Req" } + }); +#pragma warning restore CS0618 + + yaml.Should().Contain("Opt:").And.Contain("required: false"); + yaml.Should().Contain("Req:").And.Contain("required: true"); + yaml.Should().NotContain("type:"); + } + + // Ordering guard: legacy arrays emit before typed inputs. + [Fact] + public void Legacy_arrays_emit_before_typed_inputs() + { +#pragma warning disable CS0618 + var yaml = Render(new GitHubActionsWorkflowDispatchTrigger + { + OptionalInputs = new[] { "Legacy" }, + Inputs = new[] + { + new GitHubActionsWorkflowDispatchInput + { Name = "Typed", Type = GitHubActionsInputType.Boolean, Default = "true" } + } + }); +#pragma warning restore CS0618 + + yaml.IndexOf("Legacy:", StringComparison.Ordinal) + .Should().BeLessThan(yaml.IndexOf("Typed:", StringComparison.Ordinal)); + } + + // Regression guard: the obsolete arrays and the new Inputs model emit byte-identical YAML for + // equivalent inputs — migrating off the obsolete API must not change the output. + [Fact] + public void Legacy_arrays_and_typed_inputs_emit_identical_yaml() + { +#pragma warning disable CS0618 + var legacy = Render(new GitHubActionsWorkflowDispatchTrigger + { + OptionalInputs = new[] { "Opt" }, + RequiredInputs = new[] { "Req" } + }); +#pragma warning restore CS0618 + + var typed = Render(new GitHubActionsWorkflowDispatchTrigger + { + Inputs = new[] + { + new GitHubActionsWorkflowDispatchInput { Name = "Opt", Required = false }, + new GitHubActionsWorkflowDispatchInput { Name = "Req", Required = true } + } + }); + + typed.Should().Be(legacy); + } +} From 15ba8df082d7f66f10fd27fcd8881092d520bfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Mon, 29 Jun 2026 09:14:09 +0200 Subject: [PATCH 7/8] Tighten dispatch-input comments to rationale-only Trim the new inline comments to explain why (the test-injection seam, the backward-compatible legacy fold, the spaced-name normalization) rather than restating the code, matching the repo's concise comment style. --- .../Configuration/GitHubActionsWorkflowDispatchTrigger.cs | 4 ++-- .../CI/GitHubActions/GitHubActionsAttribute.cs | 6 ++---- .../CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs index 28b11399..7c27517e 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs @@ -50,8 +50,8 @@ void WriteInput(GitHubActionsWorkflowDispatchInput input) } } - // Legacy OptionalInputs/RequiredInputs emit first as untyped string inputs, preserving existing - // output; typed Inputs follow. Direct consumers of the obsolete arrays keep working. + // The obsolete arrays stay functional for direct consumers: legacy entries emit first (untyped, + // preserving prior output), then typed Inputs. private IEnumerable GetInputs() { #pragma warning disable CS0618 // deliberate bridge for the obsolete legacy arrays diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index a74eebb0..098f44e4 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -358,13 +358,11 @@ protected virtual IEnumerable GetTriggers() // must be normalized the same way to match the workflow it names. private static string NormalizeWorkflowName(string name) => name.Replace(oldChar: ' ', newChar: '_'); - // The typed [GitHubActionsInput] attributes declared on the build class. Overridable so tests can - // inject inputs without static class-level attributes (mirrors the GetJobs/GetImports/GetTriggers seams). + // Overridable so tests can inject inputs without declaring static class-level attributes. protected virtual IEnumerable DeclaredInputs => Build.GetType().GetCustomAttributes(); - // The names of every [GitHubActions] workflow declared on the build class — the valid targets for an - // input's Workflows scope. The current workflow is always among them in production. + // The valid targets for an input's Workflows scope; overridable for tests. protected virtual ISet DeclaredWorkflowNames => Build.GetType().GetCustomAttributes().Select(x => x.IdPostfix).ToHashSet(); diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs index b6cf6560..3cdcc80d 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs @@ -8,9 +8,8 @@ namespace Fallout.Common.Specs.CI; -// Exercises the trigger's public API directly — the obsolete OptionalInputs/RequiredInputs arrays that -// a consumer overriding GetTriggers() can still set, which the attribute path never populates. Guards -// the backward-compatible legacy fold in GitHubActionsWorkflowDispatchTrigger.GetInputs(). +// Guards the trigger's obsolete OptionalInputs/RequiredInputs arrays directly: the attribute path only +// sets Inputs, so the legacy fold is otherwise untested but still reachable via a GetTriggers() override. public class GitHubActionsWorkflowDispatchTriggerSpecs { private static string Render(GitHubActionsWorkflowDispatchTrigger trigger) From 92bb537f47b67547ffbd161d6799a64947ae2ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Fri, 10 Jul 2026 10:46:45 +0200 Subject: [PATCH 8/8] Report legacy workflow_dispatch input obsoletions under FALLOUTOBS001 Give the four obsolete workflow_dispatch input arrays a DiagnosticId and UrlFormat so consumers building with TreatWarningsAsErrors can NoWarn this one deprecation instead of blanket-disabling CS0618. A custom DiagnosticId replaces CS0618, so the internal bridge and test suppressions are flipped to disable FALLOUTOBS001. Generated YAML is unchanged (compile-time consumer diagnostic only), so no snapshots move. Register FALLOUTOBS001 in the docs/obsolete_apis.md registry. --- docs/obsolete_apis.md | 4 ++-- .../GitHubActionsWorkflowDispatchTrigger.cs | 12 ++++++++---- .../CI/GitHubActions/GitHubActionsAttribute.cs | 12 ++++++++---- .../CI/ConfigurationGenerationSpecs.cs | 8 ++++---- .../CI/GitHubActionsDispatchInputSpecs.cs | 4 ++-- .../CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs | 12 ++++++------ 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/docs/obsolete_apis.md b/docs/obsolete_apis.md index e2124252..a0618bac 100644 --- a/docs/obsolete_apis.md +++ b/docs/obsolete_apis.md @@ -45,10 +45,10 @@ Status values: **Deprecated** (live, warns on use), **Removed** (API deleted — | ID | Surface | Deprecated | Status | Notes | |----|---------|------------|--------|-------| -| _none yet_ | | | | | +| `FALLOUTOBS001` | `GitHubActionsAttribute.OnWorkflowDispatch{Optional,Required}Inputs`, `GitHubActionsWorkflowDispatchTrigger.{Optional,Required}Inputs` | 2026.x | Deprecated | Untyped `workflow_dispatch` input arrays. Use `[GitHubActionsInputAttribute]` / `GitHubActionsWorkflowDispatchTrigger.Inputs` instead. Removal target: 2027.0.0. | diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs index 7c27517e..08d3e9b0 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsWorkflowDispatchTrigger.cs @@ -8,10 +8,14 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; public class GitHubActionsWorkflowDispatchTrigger : GitHubActionsDetailedTrigger { - [Obsolete($"Set {nameof(Inputs)} instead. Removed in 2027.x.x.")] + [Obsolete($"Set {nameof(Inputs)} instead. Removed in 2027.x.x.", + DiagnosticId = "FALLOUTOBS001", + UrlFormat = "https://github.com/Fallout-build/Fallout/blob/main/docs/obsolete_apis.md")] public string[] OptionalInputs { get; set; } = new string[0]; - [Obsolete($"Set {nameof(Inputs)} instead. Removed in 2027.x.x.")] + [Obsolete($"Set {nameof(Inputs)} instead. Removed in 2027.x.x.", + DiagnosticId = "FALLOUTOBS001", + UrlFormat = "https://github.com/Fallout-build/Fallout/blob/main/docs/obsolete_apis.md")] public string[] RequiredInputs { get; set; } = new string[0]; public GitHubActionsWorkflowDispatchInput[] Inputs { get; set; } = new GitHubActionsWorkflowDispatchInput[0]; @@ -54,12 +58,12 @@ void WriteInput(GitHubActionsWorkflowDispatchInput input) // preserving prior output), then typed Inputs. private IEnumerable GetInputs() { -#pragma warning disable CS0618 // deliberate bridge for the obsolete legacy arrays +#pragma warning disable FALLOUTOBS001 // deliberate bridge for the obsolete legacy arrays foreach (var input in OptionalInputs) yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = false }; foreach (var input in RequiredInputs) yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = true }; -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 foreach (var input in Inputs) yield return input; diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 098f44e4..eeefef02 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -56,9 +56,13 @@ public GitHubActionsAttribute( public string[] OnPullRequestTags { get; set; } = new string[0]; public string[] OnPullRequestIncludePaths { get; set; } = new string[0]; public string[] OnPullRequestExcludePaths { get; set; } = new string[0]; - [Obsolete($"Use [{nameof(GitHubActionsInputAttribute)}] instead. Removed in 2027.x.x.")] + [Obsolete($"Use [{nameof(GitHubActionsInputAttribute)}] instead. Removed in 2027.x.x.", + DiagnosticId = "FALLOUTOBS001", + UrlFormat = "https://github.com/Fallout-build/Fallout/blob/main/docs/obsolete_apis.md")] public string[] OnWorkflowDispatchOptionalInputs { get; set; } = new string[0]; - [Obsolete($"Use [{nameof(GitHubActionsInputAttribute)}] instead. Removed in 2027.x.x.")] + [Obsolete($"Use [{nameof(GitHubActionsInputAttribute)}] instead. Removed in 2027.x.x.", + DiagnosticId = "FALLOUTOBS001", + UrlFormat = "https://github.com/Fallout-build/Fallout/blob/main/docs/obsolete_apis.md")] public string[] OnWorkflowDispatchRequiredInputs { get; set; } = new string[0]; public string OnCronSchedule { get; set; } @@ -369,12 +373,12 @@ protected virtual ISet DeclaredWorkflowNames private IEnumerable GetWorkflowDispatchInputs() { // legacy arrays first → untyped string inputs, preserving the existing ordering and output -#pragma warning disable CS0618 // deliberate bridge for the obsolete legacy arrays +#pragma warning disable FALLOUTOBS001 // deliberate bridge for the obsolete legacy arrays foreach (var input in OnWorkflowDispatchOptionalInputs) yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = false }; foreach (var input in OnWorkflowDispatchRequiredInputs) yield return new GitHubActionsWorkflowDispatchInput { Name = input, Required = true }; -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 foreach (var input in DeclaredInputs.Where(x => x.Workflows.Length == 0 || x.Workflows.Select(NormalizeWorkflowName).Contains(_name))) diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs index f2e9c4a4..9ace104a 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -146,10 +146,10 @@ public class TestBuild : FalloutBuild OnPullRequestTags = new[] { "pull_request_tag" }, OnPullRequestIncludePaths = new[] { "pull_request_include_path" }, OnPullRequestExcludePaths = new[] { "pull_request_exclude_path/**" }, -#pragma warning disable CS0618 // regression guard: the obsolete legacy path must still emit correctly +#pragma warning disable FALLOUTOBS001 // regression guard: the obsolete legacy path must still emit correctly OnWorkflowDispatchOptionalInputs = new[] { "OptionalInput" }, OnWorkflowDispatchRequiredInputs = new[] { "RequiredInput" }, -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 PublishCondition = "success() || failure()", Submodules = GitHubActionsSubmodules.Recursive, Lfs = true, @@ -308,10 +308,10 @@ public class TestBuild : FalloutBuild new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) { InvokedTargets = new[] { nameof(Test) }, -#pragma warning disable CS0618 // regression guard: the obsolete legacy path must still emit correctly +#pragma warning disable FALLOUTOBS001 // regression guard: the obsolete legacy path must still emit correctly OnWorkflowDispatchOptionalInputs = new[] { "LegacyOptional" }, OnWorkflowDispatchRequiredInputs = new[] { "LegacyRequired" }, -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 Inputs = new[] { new GitHubActionsInputAttribute("TypedFlag") { Type = GitHubActionsInputType.Boolean, Default = "true" } diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs index 21574382..afbfb06d 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs @@ -30,14 +30,14 @@ private static string Render(TestGitHubActionsAttribute attribute) [Fact] public void Legacy_arrays_and_typed_string_inputs_emit_identical_yaml() { -#pragma warning disable CS0618 // comparing the obsolete API against its typed replacement on purpose +#pragma warning disable FALLOUTOBS001 // comparing the obsolete API against its typed replacement on purpose var legacy = Render(new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) { InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, OnWorkflowDispatchOptionalInputs = new[] { "Opt" }, OnWorkflowDispatchRequiredInputs = new[] { "Req" } }); -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 var typed = Render(new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) { diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs index 3cdcc80d..6d1e43f9 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsWorkflowDispatchTriggerSpecs.cs @@ -27,13 +27,13 @@ private static string Render(GitHubActionsWorkflowDispatchTrigger trigger) [Fact] public void Legacy_arrays_emit_untyped_inputs() { -#pragma warning disable CS0618 // exercising the restored obsolete trigger API on purpose +#pragma warning disable FALLOUTOBS001 // exercising the restored obsolete trigger API on purpose var yaml = Render(new GitHubActionsWorkflowDispatchTrigger { OptionalInputs = new[] { "Opt" }, RequiredInputs = new[] { "Req" } }); -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 yaml.Should().Contain("Opt:").And.Contain("required: false"); yaml.Should().Contain("Req:").And.Contain("required: true"); @@ -44,7 +44,7 @@ public void Legacy_arrays_emit_untyped_inputs() [Fact] public void Legacy_arrays_emit_before_typed_inputs() { -#pragma warning disable CS0618 +#pragma warning disable FALLOUTOBS001 var yaml = Render(new GitHubActionsWorkflowDispatchTrigger { OptionalInputs = new[] { "Legacy" }, @@ -54,7 +54,7 @@ public void Legacy_arrays_emit_before_typed_inputs() { Name = "Typed", Type = GitHubActionsInputType.Boolean, Default = "true" } } }); -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 yaml.IndexOf("Legacy:", StringComparison.Ordinal) .Should().BeLessThan(yaml.IndexOf("Typed:", StringComparison.Ordinal)); @@ -65,13 +65,13 @@ public void Legacy_arrays_emit_before_typed_inputs() [Fact] public void Legacy_arrays_and_typed_inputs_emit_identical_yaml() { -#pragma warning disable CS0618 +#pragma warning disable FALLOUTOBS001 var legacy = Render(new GitHubActionsWorkflowDispatchTrigger { OptionalInputs = new[] { "Opt" }, RequiredInputs = new[] { "Req" } }); -#pragma warning restore CS0618 +#pragma warning restore FALLOUTOBS001 var typed = Render(new GitHubActionsWorkflowDispatchTrigger {