diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs
index 97ad0c9c4..7c336a6f7 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs
@@ -11,6 +11,7 @@ public class GitHubActionsConfiguration : ConfigurationEntity
public GitHubActionsTrigger[] ShortTriggers { get; set; }
public GitHubActionsDetailedTrigger[] DetailedTriggers { get; set; }
+ public string[] Env { get; set; } = new string[0];
public (GitHubActionsPermissions Type, string Permission)[] Permissions { get; set; }
public string ConcurrencyGroup { get; set; }
public bool ConcurrencyCancelInProgress { get; set; }
@@ -32,6 +33,16 @@ public override void Write(CustomFileWriter writer)
}
}
+ if (Env.Length > 0)
+ {
+ writer.WriteLine();
+ writer.WriteLine("env:");
+ using (writer.Indent())
+ {
+ Env.ForEach(x => writer.WriteLine(x));
+ }
+ }
+
if (Permissions.Length > 0)
{
writer.WriteLine();
diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
index a2350477f..262a7b9b7 100644
--- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
@@ -58,6 +58,16 @@ public GitHubActionsAttribute(
public string[] OnWorkflowDispatchRequiredInputs { get; set; } = new string[0];
public string OnCronSchedule { get; set; }
+ ///
+ /// Workflow-level environment variables, each entry in KEY: value form. Emitted once as a
+ /// top-level env: block (after on:) and inherited by every job and step — including
+ /// non-run steps such as checkout, cache, and artifact upload, which per-step env can't reach.
+ ///
+ /// Named Env rather than Environment to avoid confusion with the deployment
+ /// environment: keyword exposed via .
+ ///
+ public string[] Env { get; set; } = new string[0];
+
public string[] ImportSecrets { get; set; } = new string[0];
public bool EnableGitHubToken { get; set; }
public GitHubActionsPermissions[] WritePermissions { get; set; } = new GitHubActionsPermissions[0];
@@ -136,11 +146,25 @@ public override CustomFileWriter CreateWriter(StreamWriter streamWriter)
public override ConfigurationEntity GetConfiguration(IReadOnlyCollection relevantTargets)
{
+ foreach (var variable in Env)
+ {
+ Assert.True(variable != null, $"'{nameof(Env)}' entries must not be null; expected 'KEY: value'");
+
+ var separatorIndex = variable.IndexOf(':');
+ Assert.True(separatorIndex > 0,
+ $"'{nameof(Env)}' entry '{variable}' must be in 'KEY: value' form with a non-empty key");
+ Assert.True(!variable.Substring(startIndex: 0, separatorIndex).Any(char.IsWhiteSpace),
+ $"'{nameof(Env)}' entry '{variable}' has whitespace in its key; expected 'KEY: value'");
+ Assert.True(separatorIndex == variable.Length - 1 || char.IsWhiteSpace(variable[separatorIndex + 1]),
+ $"'{nameof(Env)}' entry '{variable}' must have a space after the key's colon; expected 'KEY: value'");
+ }
+
var configuration = new GitHubActionsConfiguration
{
Name = _name,
ShortTriggers = On,
DetailedTriggers = GetTriggers().ToArray(),
+ Env = Env,
Permissions = WritePermissions.Select(x => (x, "write"))
.Concat(ReadPermissions.Select(x => (x, "read"))).ToArray(),
ConcurrencyGroup = ConcurrencyGroup,
diff --git a/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=env-block-with-permissions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=env-block-with-permissions_attribute=GitHubActionsAttribute.verified.txt
new file mode 100644
index 000000000..403a9536f
--- /dev/null
+++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=env-block-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]
+
+env:
+ DOTNET_NOLOGO: true
+ Configuration: Release
+
+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
+
+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=env-block_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=env-block_attribute=GitHubActionsAttribute.verified.txt
new file mode 100644
index 000000000..c2e2b5b9b
--- /dev/null
+++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.Test_testName=env-block_attribute=GitHubActionsAttribute.verified.txt
@@ -0,0 +1,62 @@
+# ------------------------------------------------------------------------------
+#
+#
+# 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, pull_request]
+
+env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: 1
+ DOTNET_NOLOGO: true
+ NUGET_XMLDOC_MODE: skip
+ Configuration: Release
+
+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 ce164bfb5..bbbe05d5a 100644
--- a/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs
+++ b/tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs
@@ -179,6 +179,39 @@ public class TestBuild : FalloutBuild
}
);
+ yield return
+ (
+ "env-block",
+ new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push, GitHubActionsTrigger.PullRequest },
+ InvokedTargets = new[] { nameof(Test) },
+ Env = new[]
+ {
+ "DOTNET_CLI_TELEMETRY_OPTOUT: 1",
+ "DOTNET_NOLOGO: true",
+ "NUGET_XMLDOC_MODE: skip",
+ "Configuration: Release"
+ }
+ }
+ );
+
+ // Ordering guard: with Env, permissions, and concurrency all set, the env: block must be
+ // emitted after on: and before permissions:/concurrency:/jobs:, with correct blank lines.
+ yield return
+ (
+ "env-block-with-permissions",
+ new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(Test) },
+ Env = new[] { "DOTNET_NOLOGO: true", "Configuration: Release" },
+ WritePermissions = new[] { GitHubActionsPermissions.Contents },
+ ReadPermissions = new[] { GitHubActionsPermissions.Actions },
+ ConcurrencyCancelInProgress = true
+ }
+ );
+
yield return
(
null,
diff --git a/tests/Fallout.Common.Tests/CI/GitHubActionsEnvValidationTest.cs b/tests/Fallout.Common.Tests/CI/GitHubActionsEnvValidationTest.cs
new file mode 100644
index 000000000..8002193c0
--- /dev/null
+++ b/tests/Fallout.Common.Tests/CI/GitHubActionsEnvValidationTest.cs
@@ -0,0 +1,63 @@
+using System;
+using Fallout.Common.CI;
+using Fallout.Common.CI.GitHubActions;
+using Fallout.Common.Execution;
+using Fallout.Common.Tooling;
+using FluentAssertions;
+using Xunit;
+
+namespace Fallout.Common.Tests.CI;
+
+public class GitHubActionsEnvValidationTest
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("MISSING_COLON")]
+ [InlineData(": value")]
+ [InlineData("KEY WITH SPACE: 1")]
+ [InlineData("KEY : value")]
+ [InlineData("KEY:value")]
+ public void Malformed_env_entry_throws(string badEntry)
+ {
+ var act = () => GetConfiguration(new[] { badEntry });
+
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData("DOTNET_NOLOGO: true")]
+ [InlineData("Url: https://example.com")]
+ [InlineData("EMPTY_VALUE:")]
+ public void Well_formed_env_entry_does_not_throw(string goodEntry)
+ {
+ var act = () => GetConfiguration(new[] { goodEntry });
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Malformed_entry_after_a_valid_one_still_throws()
+ {
+ var act = () => GetConfiguration(new[] { "GOOD: 1", "BAD" });
+
+ act.Should().Throw();
+ }
+
+ private static void GetConfiguration(string[] env)
+ {
+ var build = new ConfigurationGenerationTest.TestBuild();
+ var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile);
+
+ var attribute = new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest)
+ {
+ On = new[] { GitHubActionsTrigger.Push },
+ InvokedTargets = new[] { nameof(ConfigurationGenerationTest.TestBuild.Test) },
+ Env = env
+ };
+ ((ConfigurationAttributeBase)attribute).Build = build;
+
+ attribute.GetConfiguration(relevantTargets);
+ }
+}