Skip to content
4 changes: 2 additions & 2 deletions docs/obsolete_apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

<!--
Allocation example (do not uncomment unless a real API is deprecated):

| `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. |
| `FALLOUTOBS002` | `Fallout.Namespace.SomeType.OldMember` | 2026.x | Deprecated | Use `NewMember` instead. Removal target: 2027.0.0. |
-->
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
using System;
using System.Linq;
using System;
using System.Collections.Generic;
using Fallout.Common.Tooling;
using Fallout.Common.Utilities;
using Fallout.Common.Utilities.Collections;

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.",
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.",
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];

public override void Write(CustomFileWriter writer)
{
Expand All @@ -18,19 +28,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));
}
}
}

// The obsolete arrays stay functional for direct consumers: legacy entries emit first (untyped,
// preserving prior output), then typed Inputs.
private IEnumerable<GitHubActionsWorkflowDispatchInput> GetInputs()
{
#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 FALLOUTOBS001

foreach (var input in Inputs)
yield return input;
}
}
102 changes: 91 additions & 11 deletions src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,7 +33,7 @@ public GitHubActionsAttribute(
GitHubActionsImage image,
params GitHubActionsImage[] images)
{
_name = name.Replace(oldChar: ' ', newChar: '_');
_name = NormalizeWorkflowName(name);
_images = new[] { image }.Concat(images).ToArray();
}

Expand All @@ -54,7 +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.",
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.",
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; }

Expand Down Expand Up @@ -191,6 +199,8 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection<Executa
$"'{nameof(Env)}' entry '{variable}' must have a space after the key's colon; expected 'KEY: value'");
}

ValidateWorkflowDispatchInputs();

var configuration = new GitHubActionsConfiguration
{
Name = _name,
Expand Down Expand Up @@ -285,8 +295,8 @@ private IEnumerable<GitHubActionsStep> GetSteps(IReadOnlyCollection<ExecutableTa

protected virtual IEnumerable<(string Key, string Value)> 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()} }}}}";
Expand Down Expand Up @@ -340,17 +350,87 @@ protected virtual IEnumerable<GitHubActionsDetailedTrigger> 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 };
}

// 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: '_');

// Overridable so tests can inject inputs without declaring static class-level attributes.
protected virtual IEnumerable<GitHubActionsInputAttribute> DeclaredInputs
=> Build.GetType().GetCustomAttributes<GitHubActionsInputAttribute>();

// The valid targets for an input's Workflows scope; overridable for tests.
protected virtual ISet<string> DeclaredWorkflowNames
=> Build.GetType().GetCustomAttributes<GitHubActionsAttribute>().Select(x => x.IdPostfix).ToHashSet();

private IEnumerable<GitHubActionsWorkflowDispatchInput> GetWorkflowDispatchInputs()
{
// legacy arrays first → untyped string inputs, preserving the existing ordering and output
#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 FALLOUTOBS001

foreach (var input in DeclaredInputs.Where(x => x.Workflows.Length == 0 ||
x.Workflows.Select(NormalizeWorkflowName).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.Select(NormalizeWorkflowName).ToHashSet();

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(NormalizeWorkflowName(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}'");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;

namespace Fallout.Common.CI.GitHubActions;

/// <summary>
/// Declares a typed <c>workflow_dispatch</c> input. Repeatable on the build class and collected by the
/// <see cref="GitHubActionsAttribute"/> generator — the typed, compile-checked replacement for the
/// <c>OnWorkflowDispatch*Inputs</c> string arrays. Emits <c>type:</c>/<c>default:</c>/<c>options:</c>
/// into each matching workflow's <c>workflow_dispatch:</c> trigger.
/// <para/>
/// Scope with <see cref="Workflows"/>: empty applies the input to every dispatch workflow on the class;
/// otherwise only to the named ones. Misconfiguration (e.g. <see cref="GitHubActionsInputType.Choice"/>
/// without <see cref="Options"/>, a default outside the options, an unknown workflow name, a blank name)
/// fails generation loudly rather than emitting broken YAML.
/// <para/>
/// <see cref="Default"/> is emitted verbatim — for free-form <see cref="GitHubActionsInputType.String"/>
/// and <see cref="GitHubActionsInputType.Environment"/> inputs the caller owns YAML-correct quoting.
/// </summary>
[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];
}
16 changes: 16 additions & 0 deletions src/Fallout.Common/CI/GitHubActions/GitHubActionsInputType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Fallout.Common.Tooling;

namespace Fallout.Common.CI.GitHubActions;

/// <summary>
/// The <c>type</c> of a <c>workflow_dispatch</c> input. See
/// <a href="https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs">workflow_dispatch.inputs</a>.
/// </summary>
public enum GitHubActionsInputType
{
[EnumValue("string")] String,
[EnumValue("boolean")] Boolean,
[EnumValue("number")] Number,
[EnumValue("choice")] Choice,
[EnumValue("environment")] Environment
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ------------------------------------------------------------------------------
# <auto-generated>
#
# 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
#
# </auto-generated>
# ------------------------------------------------------------------------------

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
Loading
Loading