diff --git a/docs/website/05-cicd/github-actions.md b/docs/website/05-cicd/github-actions.md
index ec80f02ea..0ceaacc15 100644
--- a/docs/website/05-cicd/github-actions.md
+++ b/docs/website/05-cicd/github-actions.md
@@ -263,3 +263,14 @@ CheckoutAction = "releases/v1" // error: ambiguous, names neither form
:::note
`actions/cache@v5` and later run on the node24 runtime and require a self-hosted runner of at least `2.327.1`. Set `CacheAction = "v4"` if your runners are older.
:::
+
+### Pinning the .NET SDK
+
+By default the setup step reads the SDK from `global.json` at the repo root (`global-json-file: global.json`). Set `SetupDotNetVersions` to emit `dotnet-version` instead. That covers a `global.json` kept in a subfolder, and pinning an exact SDK stops self-hosted runners that share one dotnet directory from all installing each new patch at the same time. One value is written inline, several as a `|` block; `global-json-file` is dropped. Left empty, the output is unchanged.
+
+```csharp title="Build.cs"
+[GitHubActions(
+ // ...
+ SetupDotNetVersions = new[] { "8.0.x", "10.0.103" })]
+class Build : FalloutBuild { /* ... */ }
+```
diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs
index 28a4b48da..1fb42b989 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Linq;
using Fallout.Common.Utilities;
using Fallout.Common.Utilities.Collections;
@@ -19,6 +20,13 @@ public string SetupDotNetAction
GitHubActionsDefaults.SetupDotNetAction, value, $"{nameof(GitHubActionsRunStep)}.{nameof(SetupDotNetAction)}");
} = GitHubActionsDefaults.SetupDotNetAction;
+ ///
+ /// SDK versions to install, emitted as dotnet-version in place of global-json-file. One value
+ /// is written inline, several as a | block. Null, empty, or only blank entries keep
+ /// global-json-file: global.json.
+ ///
+ public string[] SetupDotNetVersions { get; set; }
+
public string[] InvokedTargets { get; set; }
public Dictionary Imports { get; set; }
@@ -32,7 +40,21 @@ public override void Write(CustomFileWriter writer)
writer.WriteLine("with:");
using (writer.Indent())
{
- writer.WriteLine("global-json-file: global.json");
+ var versions = (SetupDotNetVersions ?? [])
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .ToArray();
+
+ if (versions.Length == 0)
+ writer.WriteLine("global-json-file: global.json");
+ else if (versions.Length == 1)
+ writer.WriteLine($"dotnet-version: {versions[0]}");
+ else
+ {
+ writer.WriteLine("dotnet-version: |");
+ using (writer.Indent())
+ versions.ForEach(x => writer.WriteLine(x));
+ }
}
}
diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
index f9ce74091..2452263e3 100644
--- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
@@ -251,6 +251,14 @@ public string CheckoutRef
///
public string SetupDotNetAction { get; set; } = GitHubActionsDefaults.SetupDotNetAction;
+ ///
+ /// SDK versions the generated actions/setup-dotnet step installs, emitted as dotnet-version
+ /// (one value inline, several as a | block). Use it when global.json isn't at the repo root,
+ /// or to pin an exact SDK so runners sharing one dotnet directory don't all install each new patch at once.
+ /// Empty (the default) keeps global-json-file: global.json.
+ ///
+ public string[] SetupDotNetVersions { get; set; } = [];
+
///
/// The actions/upload-artifact reference the generated workflow uses; same forms as
/// .
@@ -354,6 +362,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev
var run = new GitHubActionsRunStep
{
SetupDotNetAction = SetupDotNetAction,
+ SetupDotNetVersions = SetupDotNetVersions,
InvokedTargets = InvokedTargets,
Imports = GetImports().ToDictionary(x => x.Key, x => x.Value)
};
diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=setup-dotnet-version_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=setup-dotnet-version_attribute=GitHubActionsAttribute.verified.txt
new file mode 100644
index 000000000..765c94703
--- /dev/null
+++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=setup-dotnet-version_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: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: 'Cache: .fallout/temp, ~/.nuget/packages'
+ uses: actions/cache@v6
+ with:
+ path: |
+ .fallout/temp
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Setup: .NET SDK'
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.103
+ - name: 'Restore: dotnet tools'
+ run: dotnet tool restore
+ - name: 'Run: Test'
+ run: dotnet fallout Test
+ - name: 'Publish: src'
+ uses: actions/upload-artifact@v7
+ with:
+ name: src
+ path: src
+ - name: 'Publish: test-results'
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-results
+ path: output/test-results
+ - name: 'Publish: coverage-report.zip'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report.zip
+ path: output/coverage-report.zip
diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=setup-dotnet-versions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=setup-dotnet-versions_attribute=GitHubActionsAttribute.verified.txt
new file mode 100644
index 000000000..f0d545051
--- /dev/null
+++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=setup-dotnet-versions_attribute=GitHubActionsAttribute.verified.txt
@@ -0,0 +1,58 @@
+# ------------------------------------------------------------------------------
+#
+#
+# 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: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: 'Cache: .fallout/temp, ~/.nuget/packages'
+ uses: actions/cache@v6
+ with:
+ path: |
+ .fallout/temp
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Setup: .NET SDK'
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.103
+ - name: 'Restore: dotnet tools'
+ run: dotnet tool restore
+ - name: 'Run: Test'
+ run: dotnet fallout Test
+ - name: 'Publish: src'
+ uses: actions/upload-artifact@v7
+ with:
+ name: src
+ path: src
+ - name: 'Publish: test-results'
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-results
+ path: output/test-results
+ - name: 'Publish: coverage-report.zip'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report.zip
+ path: output/coverage-report.zip
diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs
index 6100d5a76..4ba326106 100644
--- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs
+++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs
@@ -612,6 +612,29 @@ public class TestBuild : FalloutBuild
}
);
+ // Pinned SDK versions replace global-json-file; one is written inline, several as a block.
+ yield return
+ (
+ "setup-dotnet-version",
+ new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(Test) },
+ SetupDotNetVersions = new[] { "10.0.103" }
+ }
+ );
+
+ yield return
+ (
+ "setup-dotnet-versions",
+ new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(Test) },
+ SetupDotNetVersions = new[] { "8.0.x", "10.0.103" }
+ }
+ );
+
// Every emitted action is overridable, in each accepted form: a bare ref appended to the default
// action name, a complete reference, a SHA pin carrying its version as a trailing comment, and a
// fork. Pack is invoked because it produces artifacts, so the upload step actually renders.