diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs
index 7c336a6f..4af60abb 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs
@@ -15,6 +15,7 @@ public class GitHubActionsConfiguration : ConfigurationEntity
public (GitHubActionsPermissions Type, string Permission)[] Permissions { get; set; }
public string ConcurrencyGroup { get; set; }
public bool ConcurrencyCancelInProgress { get; set; }
+ public string DefaultShell { get; set; }
public GitHubActionsJob[] Jobs { get; set; }
public override void Write(CustomFileWriter writer)
@@ -75,6 +76,21 @@ public override void Write(CustomFileWriter writer)
}
}
+ if (!DefaultShell.IsNullOrWhiteSpace())
+ {
+ writer.WriteLine();
+ // defaults.run currently carries only shell; further run defaults (e.g. working-directory) slot in here
+ writer.WriteLine("defaults:");
+ using (writer.Indent())
+ {
+ writer.WriteLine("run:");
+ using (writer.Indent())
+ {
+ writer.WriteLine($"shell: {DefaultShell}");
+ }
+ }
+ }
+
writer.WriteLine();
writer.WriteLine("jobs:");
diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
index 262a7b9b..9f9a2b54 100644
--- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
@@ -91,6 +91,15 @@ public GitHubActionsAttribute(
public string JobConcurrencyGroup { get; set; }
public bool JobConcurrencyCancelInProgress { get; set; }
+ ///
+ /// Pins the shell for every run: step via a workflow-level defaults.run.shell block,
+ /// so cross-platform matrix jobs use one consistent shell instead of the per-OS default (bash
+ /// on Linux/macOS, pwsh on Windows). Accepts any value GitHub allows — a built-in (bash,
+ /// pwsh, sh, cmd, powershell, python) or a custom command {0}
+ /// template. Unset or whitespace-only emits no defaults: block.
+ ///
+ public string DefaultShell { get; set; }
+
public string[] InvokedTargets { get; set; } = new string[0];
public GitHubActionsSubmodules Submodules
@@ -169,6 +178,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection (x, "read"))).ToArray(),
ConcurrencyGroup = ConcurrencyGroup,
ConcurrencyCancelInProgress = ConcurrencyCancelInProgress,
+ DefaultShell = DefaultShell,
Jobs = _images.Select(x => GetJobs(x, relevantTargets)).ToArray()
};
diff --git a/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt
new file mode 100644
index 00000000..6b8e5188
--- /dev/null
+++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt
@@ -0,0 +1,68 @@
+# ------------------------------------------------------------------------------
+#
+#
+# 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]
+
+permissions:
+ contents: write
+ actions: read
+
+concurrency:
+ group: ${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ 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.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt
new file mode 100644
index 00000000..858b288b
--- /dev/null
+++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt
@@ -0,0 +1,60 @@
+# ------------------------------------------------------------------------------
+#
+#
+# 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]
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ 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..fd7de7be 100644
--- a/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs
+++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs
@@ -212,6 +212,33 @@ public class TestBuild : FalloutBuild
}
);
+ yield return
+ (
+ "default-shell",
+ new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(Test) },
+ DefaultShell = "pwsh"
+ }
+ );
+
+ // Ordering guard: with DefaultShell, permissions, and concurrency all set, the defaults:
+ // block must be emitted after concurrency: and before jobs:, with correct blank lines.
+ yield return
+ (
+ "default-shell-with-permissions",
+ new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(Test) },
+ DefaultShell = "pwsh",
+ WritePermissions = new[] { GitHubActionsPermissions.Contents },
+ ReadPermissions = new[] { GitHubActionsPermissions.Actions },
+ ConcurrencyCancelInProgress = true
+ }
+ );
+
yield return
(
null,