diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
index f9ce74091..7107b1ea3 100644
--- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
@@ -161,6 +161,16 @@ public GitHubActionsAttribute(
///
public string[] RunsOnLabels { get; set; } = new string[0];
+ ///
+ /// Overrides the generated job's id and name:, which otherwise default to the runner image
+ /// (e.g. windows-latest). Useful with , where the image is only a
+ /// placeholder, since the job name is what shows in the PR checks list and what branch protection matches.
+ ///
+ /// Must be a valid job id (letters, digits, - and _, starting with a letter or _)
+ /// and requires exactly one image, as a matrix would produce duplicate ids. Unset keeps the default.
+ ///
+ public string JobName { get; set; }
+
public GitHubActionsSubmodules Submodules
{
set => submodules = value;
@@ -307,6 +317,10 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection !x.IsNullOrWhiteSpace()),
$"'{nameof(RunsOnLabels)}' entries must not be null, empty, or whitespace");
+ Assert.True(JobName == null || images.Length == 1,
+ $"Cannot use '{nameof(JobName)}' with multiple images; each job in the matrix would get the same id");
+ Assert.True(JobName == null || System.Text.RegularExpressions.Regex.IsMatch(JobName, "^[A-Za-z_][A-Za-z0-9_-]*$"),
+ $"'{nameof(JobName)}' must be a valid job id: letters, digits, '-' and '_', starting with a letter or '_'");
return configuration;
}
@@ -315,7 +329,7 @@ protected virtual GitHubActionsJob GetJobs(GitHubActionsImage image, IReadOnlyCo
{
return new GitHubActionsJob
{
- Name = image.GetValue().Replace(".", "_"),
+ Name = JobName ?? image.GetValue().Replace(".", "_"),
RunsOnLabels = RunsOnLabels,
EnvironmentName = EnvironmentName,
EnvironmentUrl = EnvironmentUrl,
diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsJobNameSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsJobNameSpecs.cs
new file mode 100644
index 000000000..54ced9d80
--- /dev/null
+++ b/tests/Fallout.Common.Specs/CI/GitHubActionsJobNameSpecs.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Linq;
+using Fallout.Common.CI;
+using Fallout.Common.CI.GitHubActions;
+using Fallout.Common.CI.GitHubActions.Configuration;
+using Fallout.Common.Execution;
+using Fallout.Common.Utilities;
+using FluentAssertions;
+using Xunit;
+
+namespace Fallout.Common.Specs.CI;
+
+public class GitHubActionsJobNameSpecs
+{
+ [Fact]
+ public void Unset_job_name_uses_image_value()
+ {
+ var job = GetJobs(new[] { GitHubActionsImage.WindowsLatest }, jobName: null).Single();
+
+ job.Name.Should().Be("windows-latest");
+ }
+
+ [Fact]
+ public void Unset_job_name_keeps_dots_replaced_in_matrix()
+ {
+ var jobs = GetJobs(new[] { GitHubActionsImage.UbuntuLatest, GitHubActionsImage.Ubuntu2204 }, jobName: null);
+
+ jobs.Select(x => x.Name).Should().Equal("ubuntu-latest", "ubuntu-22_04");
+ }
+
+ [Fact]
+ public void Job_name_is_used_as_job_id_and_name()
+ {
+ var job = GetJobs(new[] { GitHubActionsImage.WindowsLatest }, "build-windows").Single();
+
+ job.Name.Should().Be("build-windows");
+ using var stream = new System.IO.MemoryStream();
+ using (var streamWriter = new System.IO.StreamWriter(stream, leaveOpen: true))
+ {
+ job.Write(new CustomFileWriter(streamWriter, 2, "#"));
+ }
+
+ var output = System.Text.Encoding.UTF8.GetString(stream.ToArray());
+ output.Should().StartWith("build-windows:").And.Contain("name: build-windows");
+ }
+
+ [Fact]
+ public void Job_name_with_multiple_images_throws()
+ {
+ var act = () => GetJobs(new[] { GitHubActionsImage.UbuntuLatest, GitHubActionsImage.WindowsLatest }, "build");
+
+ act.Should().Throw().WithMessage("*JobName*");
+ }
+
+ [Theory]
+ [InlineData("build")]
+ [InlineData("_build")]
+ [InlineData("Build-1_x")]
+ public void Valid_job_name_does_not_throw(string jobName)
+ {
+ var act = () => GetJobs(new[] { GitHubActionsImage.UbuntuLatest }, jobName);
+
+ act.Should().NotThrow();
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("1build")]
+ [InlineData("-build")]
+ [InlineData("build job")]
+ [InlineData("build.job")]
+ public void Invalid_job_name_throws(string jobName)
+ {
+ var act = () => GetJobs(new[] { GitHubActionsImage.UbuntuLatest }, jobName);
+
+ act.Should().Throw().WithMessage("*JobName*");
+ }
+
+ private static GitHubActionsJob[] GetJobs(GitHubActionsImage[] images, string jobName)
+ {
+ var build = new ConfigurationGenerationSpecs.TestBuild();
+ var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile);
+ var attribute = new TestGitHubActionsAttribute(images[0], images[1..])
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) },
+ JobName = jobName
+ };
+ ((ConfigurationAttributeBase)attribute).Build = build;
+ return ((GitHubActionsConfiguration)attribute.GetConfiguration(relevantTargets)).Jobs;
+ }
+}