From aec24c4aeb2081b62d2b38d1ba60900b512c3128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Thu, 25 Jun 2026 13:57:26 +0200 Subject: [PATCH] Add DefaultShell to pin the shell for GitHub Actions run steps Cross-platform matrix jobs silently use a different default shell per OS (bash on Linux/macOS, pwsh on Windows), which changes script semantics. The generator had no way to emit an explicit shell. Add a public string DefaultShell to GitHubActionsAttribute. When set, the workflow emits a top-level defaults.run.shell block (after concurrency, before jobs), pinning one shell for every run: step across all matrix jobs. Free-string value; unset or whitespace-only emits no block. Per-step shell was deliberately scoped out (one run step per job; no granularity gain). Covered by two Verify snapshot cases (default-shell, and a default-shell-with-permissions ordering guard). --- .../GitHubActionsConfiguration.cs | 16 +++++ .../GitHubActions/GitHubActionsAttribute.cs | 10 +++ ...ribute=GitHubActionsAttribute.verified.txt | 68 +++++++++++++++++++ ...ribute=GitHubActionsAttribute.verified.txt | 60 ++++++++++++++++ .../CI/ConfigurationGenerationTest.cs | 27 ++++++++ 5 files changed, 181 insertions(+) create mode 100644 tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt 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,