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);
+ }
+}