From 31383414dc25f0b5cf4da7af309f6070d0615310 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 5 Oct 2026 13:36:56 +1300 Subject: [PATCH 1/2] Let a workflow_dispatch choice input take its options from an enum Add OptionsFrom to GitHubActionsInputAttribute so the option list is declared once. Options are resolved in a single helper shared by validation and output. Co-Authored-By: Claude Sonnet 5.5 --- .../GitHubActions/GitHubActionsAttribute.cs | 26 +++++++-- .../GitHubActionsInputAttribute.cs | 6 ++ .../CI/GitHubActionsDispatchInputSpecs.cs | 28 ++++++++++ .../CI/GitHubActionsInputValidationSpecs.cs | 55 +++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index e3c355c34..2424ce5bc 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -496,28 +496,44 @@ private IEnumerable GetWorkflowDispatchInput Type = input.Type, Required = input.Required, Default = input.Default, - Options = input.Options, + Options = ResolveOptions(input), Description = input.Description }; } + // The one place options are resolved: the explicit list, or the names of the OptionsFrom enum in declaration order. + private static string[] ResolveOptions(GitHubActionsInputAttribute input) + { + if (input.OptionsFrom == null) + return input.Options; + + Assert.True(input.Options.Length == 0, + $"'{input.Name}' sets both '{nameof(GitHubActionsInputAttribute.Options)}' and '{nameof(GitHubActionsInputAttribute.OptionsFrom)}'; use one"); + Assert.True(input.OptionsFrom.IsEnum, + $"'{input.Name}' '{nameof(GitHubActionsInputAttribute.OptionsFrom)}' must be an enum, but '{input.OptionsFrom.Name}' is not"); + Assert.True(input.Type == GitHubActionsInputType.Choice, + $"'{input.Name}' sets '{nameof(GitHubActionsInputAttribute.OptionsFrom)}' but its type is not '{nameof(GitHubActionsInputType.Choice)}'"); + return Enum.GetNames(input.OptionsFrom); + } + private void ValidateWorkflowDispatchInputs() { var declaredWorkflows = DeclaredWorkflowNames.Select(NormalizeWorkflowName).ToHashSet(); foreach (var input in DeclaredInputs) { + var options = ResolveOptions(input); if (input.Type == GitHubActionsInputType.Choice) - Assert.True(input.Options.Length > 0, - $"'{input.Name}' is a choice input and requires non-empty '{nameof(GitHubActionsInputAttribute.Options)}'"); + Assert.True(options.Length > 0, + $"'{input.Name}' is a choice input and requires non-empty '{nameof(GitHubActionsInputAttribute.Options)}' or '{nameof(GitHubActionsInputAttribute.OptionsFrom)}'"); else - Assert.True(input.Options.Length == 0, + Assert.True(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), + Assert.True(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 _), diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs index e22b46449..75f1a9b00 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsInputAttribute.cs @@ -29,6 +29,12 @@ public GitHubActionsInputAttribute(string name) public bool Required { get; set; } public string Default { get; set; } public string[] Options { get; set; } = new string[0]; + + /// + /// Takes the choice options from the names of this enum, in declaration order, so the list is declared once. + /// Requires = . Cannot be combined with . + /// + public Type OptionsFrom { get; set; } public string Description { get; set; } public string[] Workflows { get; set; } = new string[0]; } diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs index afbfb06d6..02d431152 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs @@ -52,6 +52,34 @@ public void Legacy_arrays_and_typed_string_inputs_emit_identical_yaml() typed.Should().Be(legacy); } + // Options taken from an enum emit the same YAML as the same names typed out, in declaration order. + [Fact] + public void Options_from_an_enum_emit_the_same_yaml_as_explicit_options() + { + string RenderChoice(GitHubActionsInputAttribute input) => Render(new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + Inputs = new[] { input } + }); + + var explicitOptions = RenderChoice(new GitHubActionsInputAttribute("Category") + { + Type = GitHubActionsInputType.Choice, + Options = new[] { "All", "ReadOnly", "Write" }, + Default = "ReadOnly" + }); + var fromEnum = RenderChoice(new GitHubActionsInputAttribute("Category") + { + Type = GitHubActionsInputType.Choice, + OptionsFrom = typeof(Category), + Default = "ReadOnly" + }); + + fromEnum.Should().Be(explicitOptions); + } + + private enum Category { All, ReadOnly, Write } + // 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] diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs index d53a522bc..00ee27953 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsInputValidationSpecs.cs @@ -37,6 +37,59 @@ public void Well_formed_input_does_not_throw(GitHubActionsInputType type, string act.Should().NotThrow(); } + [Fact] + public void Options_from_a_non_enum_type_throws() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") { Type = GitHubActionsInputType.Choice, OptionsFrom = typeof(string) }); + + act.Should().Throw().WithMessage("*must be an enum*"); + } + + [Fact] + public void Options_and_options_from_together_throw() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") + { + Type = GitHubActionsInputType.Choice, Options = new[] { "A" }, OptionsFrom = typeof(Category) + }); + + act.Should().Throw().WithMessage("*both*"); + } + + [Fact] + public void Options_from_without_choice_type_throws() + { + var act = () => GetConfiguration(new GitHubActionsInputAttribute("Input") { OptionsFrom = typeof(Category) }); + + act.Should().Throw().WithMessage("*not 'Choice'*"); + } + + [Fact] + public void Default_outside_the_options_from_enum_throws() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") + { + Type = GitHubActionsInputType.Choice, OptionsFrom = typeof(Category), Default = "Missing" + }); + + act.Should().Throw().WithMessage("*not one of its options*"); + } + + [Fact] + public void Default_inside_the_options_from_enum_does_not_throw() + { + var act = () => GetConfiguration( + new GitHubActionsInputAttribute("Input") + { + Type = GitHubActionsInputType.Choice, OptionsFrom = typeof(Category), Default = nameof(Category.ReadOnly) + }); + + act.Should().NotThrow(); + } + [Fact] public void Unknown_workflow_name_throws() { @@ -66,6 +119,8 @@ public void Blank_input_name_throws(string name) act.Should().Throw(); } + private enum Category { All, ReadOnly, Write } + private static void GetConfiguration(params GitHubActionsInputAttribute[] inputs) { var build = new ConfigurationGenerationSpecs.TestBuild(); From 5860c14591458388fc2d5a2da649a7a33017aca9 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 5 Oct 2026 13:54:48 +1300 Subject: [PATCH 2/2] Keep OptionsFrom names in source declaration order Enum.GetNames sorts by value, so an enum with out-of-order values gave a different dropdown order. Read the fields in declaration order instead and cover it with a non-ascending enum in the spec. Co-Authored-By: Claude Sonnet 5.5 --- src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs | 3 ++- .../CI/GitHubActionsDispatchInputSpecs.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 2424ce5bc..fe3a14e97 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -513,7 +513,8 @@ private static string[] ResolveOptions(GitHubActionsInputAttribute input) $"'{input.Name}' '{nameof(GitHubActionsInputAttribute.OptionsFrom)}' must be an enum, but '{input.OptionsFrom.Name}' is not"); Assert.True(input.Type == GitHubActionsInputType.Choice, $"'{input.Name}' sets '{nameof(GitHubActionsInputAttribute.OptionsFrom)}' but its type is not '{nameof(GitHubActionsInputType.Choice)}'"); - return Enum.GetNames(input.OptionsFrom); + // Enum.GetNames sorts by value; field order follows the source declaration order. + return input.OptionsFrom.GetFields(BindingFlags.Public | BindingFlags.Static).Select(x => x.Name).ToArray(); } private void ValidateWorkflowDispatchInputs() diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs index 02d431152..4642dc05b 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsDispatchInputSpecs.cs @@ -65,7 +65,7 @@ string RenderChoice(GitHubActionsInputAttribute input) => Render(new TestGitHubA var explicitOptions = RenderChoice(new GitHubActionsInputAttribute("Category") { Type = GitHubActionsInputType.Choice, - Options = new[] { "All", "ReadOnly", "Write" }, + Options = new[] { "Write", "All", "ReadOnly" }, Default = "ReadOnly" }); var fromEnum = RenderChoice(new GitHubActionsInputAttribute("Category") @@ -78,7 +78,7 @@ string RenderChoice(GitHubActionsInputAttribute input) => Render(new TestGitHubA fromEnum.Should().Be(explicitOptions); } - private enum Category { All, ReadOnly, Write } + private enum Category { Write = 2, All = 0, ReadOnly = 1 } // 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").