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); + } +}