Skip to content

Commit 2318fda

Browse files
committed
Emit custom runs-on label arrays for self-hosted runners (#387)
GitHubActionsJob.Write emitted runs-on: {Image.GetValue()} unconditionally, so a workflow could only target the fixed GitHubActionsImage enum. Self-hosted runner pools are selected by a label array (runs-on: [self-hosted, linux, x64]) which was impossible to produce. Add an additive string[] RunsOnLabels to GitHubActionsAttribute, plumb it through GetJobs onto GitHubActionsJob, and branch in Write: non-empty emits the array form via JoinCommaSpace(), otherwise the existing scalar path is unchanged. RunsOnLabels defaults empty, so existing usages generate byte-for-byte identical YAML. Validated at configuration-generation time: it requires exactly one image (a multi-image matrix is ambiguous) and rejects null/empty/whitespace entries that would emit malformed YAML. Covered by a runs-on-labels Verify snapshot and GitHubActionsRunsOnLabelsValidationTest.
1 parent a04bded commit 2318fda

5 files changed

Lines changed: 169 additions & 1 deletion

File tree

src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsJob.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public class GitHubActionsJob : ConfigurationEntity
1010
{
1111
public string Name { get; set; }
1212
public GitHubActionsImage Image { get; set; }
13+
public string[] RunsOnLabels { get; set; } = new string[0];
1314
public int TimeoutMinutes { get; set; }
1415
public string ConcurrencyGroup { get; set; }
1516
public string EnvironmentName { get; set; }
@@ -24,7 +25,14 @@ public override void Write(CustomFileWriter writer)
2425
using (writer.Indent())
2526
{
2627
writer.WriteLine($"name: {Name}");
27-
writer.WriteLine($"runs-on: {Image.GetValue()}");
28+
if (RunsOnLabels.Length > 0)
29+
{
30+
writer.WriteLine($"runs-on: [{RunsOnLabels.JoinCommaSpace()}]");
31+
}
32+
else
33+
{
34+
writer.WriteLine($"runs-on: {Image.GetValue()}");
35+
}
2836

2937
if (TimeoutMinutes > 0)
3038
{

src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ public GitHubActionsAttribute(
9393

9494
public string[] InvokedTargets { get; set; } = new string[0];
9595

96+
/// <summary>
97+
/// Runner labels emitted verbatim as <c>runs-on: [label1, label2, ...]</c>, for selecting a
98+
/// self-hosted runner pool by OS/arch/capability (e.g. <c>["self-hosted", "linux", "x64"]</c>).
99+
/// <para/>
100+
/// When non-empty this replaces the <c>runs-on:</c> image for the job and requires exactly one
101+
/// image (no matrix). The constructor-mandated <see cref="GitHubActionsImage"/> is then ignored
102+
/// for <c>runs-on:</c> and only names the job.
103+
/// </summary>
104+
public string[] RunsOnLabels { get; set; } = new string[0];
105+
96106
public GitHubActionsSubmodules Submodules
97107
{
98108
set => _submodules = value;
@@ -176,6 +186,10 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection<Executa
176186
$"Workflows can only define either shorthand '{nameof(On)}' or '{nameof(On)}*' triggers");
177187
Assert.True(configuration.ShortTriggers.Length > 0 || configuration.DetailedTriggers.Length > 0,
178188
$"Workflows must define either shorthand '{nameof(On)}' or '{nameof(On)}*' triggers");
189+
Assert.True(RunsOnLabels.Length == 0 || _images.Length == 1,
190+
$"Cannot use '{nameof(RunsOnLabels)}' with multiple images; labels resolve a single job's runner");
191+
Assert.True(RunsOnLabels.All(x => !x.IsNullOrWhiteSpace()),
192+
$"'{nameof(RunsOnLabels)}' entries must not be null, empty, or whitespace");
179193

180194
return configuration;
181195
}
@@ -185,6 +199,7 @@ protected virtual GitHubActionsJob GetJobs(GitHubActionsImage image, IReadOnlyCo
185199
return new GitHubActionsJob
186200
{
187201
Name = image.GetValue().Replace(".", "_"),
202+
RunsOnLabels = RunsOnLabels,
188203
EnvironmentName = EnvironmentName,
189204
EnvironmentUrl = EnvironmentUrl,
190205
Steps = GetSteps(relevantTargets).ToArray(),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# ------------------------------------------------------------------------------
2+
# <auto-generated>
3+
#
4+
# This code was generated.
5+
#
6+
# - To turn off auto-generation set:
7+
#
8+
# [TestGitHubActions (AutoGenerate = false)]
9+
#
10+
# - To trigger manual generation invoke:
11+
#
12+
# fallout --generate-configuration GitHubActions_test --host GitHubActions
13+
#
14+
# </auto-generated>
15+
# ------------------------------------------------------------------------------
16+
17+
name: test
18+
19+
on: [push]
20+
21+
jobs:
22+
ubuntu-latest:
23+
name: ubuntu-latest
24+
runs-on: [self-hosted, linux, x64]
25+
steps:
26+
- uses: actions/checkout@v6
27+
- name: 'Cache: .fallout/temp, ~/.nuget/packages'
28+
uses: actions/cache@v4
29+
with:
30+
path: |
31+
.fallout/temp
32+
~/.nuget/packages
33+
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
34+
- name: 'Setup: .NET SDK'
35+
uses: actions/setup-dotnet@v4
36+
with:
37+
global-json-file: global.json
38+
- name: 'Restore: dotnet tools'
39+
run: dotnet tool restore
40+
- name: 'Run: Test'
41+
run: dotnet fallout Test
42+
- name: 'Publish: src'
43+
uses: actions/upload-artifact@v5
44+
with:
45+
name: src
46+
path: src
47+
- name: 'Publish: test-results'
48+
uses: actions/upload-artifact@v5
49+
with:
50+
name: test-results
51+
path: output/test-results
52+
- name: 'Publish: coverage-report.zip'
53+
uses: actions/upload-artifact@v5
54+
with:
55+
name: coverage-report.zip
56+
path: output/coverage-report.zip

tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,17 @@ public class TestBuild : FalloutBuild
212212
}
213213
);
214214

215+
yield return
216+
(
217+
"runs-on-labels",
218+
new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
219+
{
220+
On = new[] { GitHubActionsTrigger.Push },
221+
InvokedTargets = new[] { nameof(Test) },
222+
RunsOnLabels = new[] { "self-hosted", "linux", "x64" }
223+
}
224+
);
225+
215226
yield return
216227
(
217228
null,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System;
2+
using Fallout.Common.CI;
3+
using Fallout.Common.CI.GitHubActions;
4+
using Fallout.Common.Execution;
5+
using FluentAssertions;
6+
using Xunit;
7+
8+
namespace Fallout.Common.Specs.CI;
9+
10+
public class GitHubActionsRunsOnLabelsValidationSpecs
11+
{
12+
[Fact]
13+
public void Matrix_with_runs_on_labels_throws()
14+
{
15+
var act = () => GetConfiguration(
16+
new[] { GitHubActionsImage.UbuntuLatest, GitHubActionsImage.WindowsLatest },
17+
new[] { "self-hosted", "linux", "x64" });
18+
19+
act.Should().Throw<Exception>().WithMessage("*RunsOnLabels*");
20+
}
21+
22+
[Fact]
23+
public void Single_image_with_runs_on_labels_does_not_throw()
24+
{
25+
var act = () => GetConfiguration(
26+
new[] { GitHubActionsImage.UbuntuLatest },
27+
new[] { "self-hosted", "linux", "x64" });
28+
29+
act.Should().NotThrow();
30+
}
31+
32+
[Fact]
33+
public void Matrix_without_runs_on_labels_does_not_throw()
34+
{
35+
var act = () => GetConfiguration(
36+
new[] { GitHubActionsImage.UbuntuLatest, GitHubActionsImage.WindowsLatest },
37+
new string[0]);
38+
39+
act.Should().NotThrow();
40+
}
41+
42+
[Fact]
43+
public void Single_label_does_not_throw()
44+
{
45+
var act = () => GetConfiguration(
46+
new[] { GitHubActionsImage.UbuntuLatest },
47+
new[] { "self-hosted" });
48+
49+
act.Should().NotThrow();
50+
}
51+
52+
[Theory]
53+
[InlineData(null)]
54+
[InlineData("")]
55+
[InlineData(" ")]
56+
public void Empty_or_whitespace_label_element_throws(string badLabel)
57+
{
58+
var act = () => GetConfiguration(
59+
new[] { GitHubActionsImage.UbuntuLatest },
60+
new[] { "self-hosted", badLabel });
61+
62+
act.Should().Throw<Exception>().WithMessage("*RunsOnLabels*");
63+
}
64+
65+
private static void GetConfiguration(GitHubActionsImage[] images, string[] runsOnLabels)
66+
{
67+
var build = new ConfigurationGenerationSpecs.TestBuild();
68+
var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile);
69+
var attribute = new TestGitHubActionsAttribute(images[0], images[1..])
70+
{
71+
On = new[] { GitHubActionsTrigger.Push },
72+
InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) },
73+
RunsOnLabels = runsOnLabels
74+
};
75+
((ConfigurationAttributeBase)attribute).Build = build;
76+
attribute.GetConfiguration(relevantTargets);
77+
}
78+
}

0 commit comments

Comments
 (0)