diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsJob.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsJob.cs index ab19c6ad..40969f71 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsJob.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsJob.cs @@ -10,6 +10,7 @@ public class GitHubActionsJob : ConfigurationEntity { public string Name { get; set; } public GitHubActionsImage Image { get; set; } + public string[] RunsOnLabels { get; set; } = new string[0]; public int TimeoutMinutes { get; set; } public string ConcurrencyGroup { get; set; } public string EnvironmentName { get; set; } @@ -24,7 +25,14 @@ public override void Write(CustomFileWriter writer) using (writer.Indent()) { writer.WriteLine($"name: {Name}"); - writer.WriteLine($"runs-on: {Image.GetValue()}"); + if (RunsOnLabels.Length > 0) + { + writer.WriteLine($"runs-on: [{RunsOnLabels.JoinCommaSpace()}]"); + } + else + { + writer.WriteLine($"runs-on: {Image.GetValue()}"); + } if (TimeoutMinutes > 0) { diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 262a7b9b..54bf1f24 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -93,6 +93,16 @@ public GitHubActionsAttribute( public string[] InvokedTargets { get; set; } = new string[0]; + /// + /// Runner labels emitted verbatim as runs-on: [label1, label2, ...], for selecting a + /// self-hosted runner pool by OS/arch/capability (e.g. ["self-hosted", "linux", "x64"]). + /// + /// When non-empty this replaces the runs-on: image for the job and requires exactly one + /// image (no matrix). The constructor-mandated is then ignored + /// for runs-on: and only names the job. + /// + public string[] RunsOnLabels { get; set; } = new string[0]; + public GitHubActionsSubmodules Submodules { set => _submodules = value; @@ -176,6 +186,10 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection 0 || configuration.DetailedTriggers.Length > 0, $"Workflows must define either shorthand '{nameof(On)}' or '{nameof(On)}*' triggers"); + Assert.True(RunsOnLabels.Length == 0 || _images.Length == 1, + $"Cannot use '{nameof(RunsOnLabels)}' with multiple images; labels resolve a single job's runner"); + Assert.True(RunsOnLabels.All(x => !x.IsNullOrWhiteSpace()), + $"'{nameof(RunsOnLabels)}' entries must not be null, empty, or whitespace"); return configuration; } @@ -185,6 +199,7 @@ protected virtual GitHubActionsJob GetJobs(GitHubActionsImage image, IReadOnlyCo return new GitHubActionsJob { Name = image.GetValue().Replace(".", "_"), + RunsOnLabels = RunsOnLabels, EnvironmentName = EnvironmentName, EnvironmentUrl = EnvironmentUrl, Steps = GetSteps(relevantTargets).ToArray(), diff --git a/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 00000000..cdd05c2c --- /dev/null +++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,56 @@ +# ------------------------------------------------------------------------------ +# +# +# 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: [self-hosted, linux, x64] + 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.Tests/CI/ConfigurationGenerationTest.cs b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs index bbbe05d5..cbc067df 100644 --- a/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs +++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs @@ -212,6 +212,17 @@ public class TestBuild : FalloutBuild } ); + yield return + ( + "runs-on-labels", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + RunsOnLabels = new[] { "self-hosted", "linux", "x64" } + } + ); + yield return ( null, diff --git a/tests/Fallout.Common.Tests/CI/GitHubActionsRunsOnLabelsValidationTest.cs b/tests/Fallout.Common.Tests/CI/GitHubActionsRunsOnLabelsValidationTest.cs new file mode 100644 index 00000000..0b586656 --- /dev/null +++ b/tests/Fallout.Common.Tests/CI/GitHubActionsRunsOnLabelsValidationTest.cs @@ -0,0 +1,78 @@ +using System; +using Fallout.Common.CI; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.Execution; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Tests.CI; + +public class GitHubActionsRunsOnLabelsValidationTest +{ + [Fact] + public void Matrix_with_runs_on_labels_throws() + { + var act = () => GetConfiguration( + new[] { GitHubActionsImage.UbuntuLatest, GitHubActionsImage.WindowsLatest }, + new[] { "self-hosted", "linux", "x64" }); + + act.Should().Throw().WithMessage("*RunsOnLabels*"); + } + + [Fact] + public void Single_image_with_runs_on_labels_does_not_throw() + { + var act = () => GetConfiguration( + new[] { GitHubActionsImage.UbuntuLatest }, + new[] { "self-hosted", "linux", "x64" }); + + act.Should().NotThrow(); + } + + [Fact] + public void Matrix_without_runs_on_labels_does_not_throw() + { + var act = () => GetConfiguration( + new[] { GitHubActionsImage.UbuntuLatest, GitHubActionsImage.WindowsLatest }, + new string[0]); + + act.Should().NotThrow(); + } + + [Fact] + public void Single_label_does_not_throw() + { + var act = () => GetConfiguration( + new[] { GitHubActionsImage.UbuntuLatest }, + new[] { "self-hosted" }); + + act.Should().NotThrow(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Empty_or_whitespace_label_element_throws(string badLabel) + { + var act = () => GetConfiguration( + new[] { GitHubActionsImage.UbuntuLatest }, + new[] { "self-hosted", badLabel }); + + act.Should().Throw().WithMessage("*RunsOnLabels*"); + } + + private static void GetConfiguration(GitHubActionsImage[] images, string[] runsOnLabels) + { + var build = new ConfigurationGenerationTest.TestBuild(); + var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile); + var attribute = new TestGitHubActionsAttribute(images[0], images[1..]) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(ConfigurationGenerationTest.TestBuild.Test) }, + RunsOnLabels = runsOnLabels + }; + ((ConfigurationAttributeBase)attribute).Build = build; + attribute.GetConfiguration(relevantTargets); + } +}