diff --git a/docs/website/05-cicd/github-actions.md b/docs/website/05-cicd/github-actions.md index ec80f02ea..19ff21f27 100644 --- a/docs/website/05-cicd/github-actions.md +++ b/docs/website/05-cicd/github-actions.md @@ -167,6 +167,30 @@ class Build : FalloutBuild If you're facing any issues, make sure that the name in the GitHub settings is the same as generated into the workflow file. ::: +#### Importing a secret under an explicit name + +`ImportSecrets` derives the secret name from the parameter name. Use `ImportSecretsAs` when the secret already exists under a name that derivation cannot produce, or when the environment variable needs a name no parameter can have, such as a .NET configuration key. Each entry is `ENV_NAME: SECRET_NAME`: + +```csharp title="Build.cs" +[GitHubActions( + // ... + ImportSecretsAs = new[] { "Apis__NzPost__FunctionKey: OPS_API_TESTS_NZPOST_KEY" })] +``` + +
+Generated output + +```yaml title=".github/workflows/continuous.yml" +- name: 'Run: Test' + run: dotnet fallout Test + env: + Apis__NzPost__FunctionKey: ${{ secrets.OPS_API_TESTS_NZPOST_KEY }} +``` + +
+ +Neither name may contain whitespace. An environment variable name used twice across `ImportSecrets`, `ImportSecretsAs` and `EnableGitHubToken` (`GITHUB_TOKEN`) fails generation. + ### Using the GitHub Token For every workflow run, GitHub generates a [one-time token](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) with [adequate permissions](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) that you can use to authenticate with the GitHub API. You can enable the GitHub token in your attribute as follows: diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index f9ce74091..6049bca6a 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -101,6 +101,17 @@ public GitHubActionsAttribute( public string[] ImportSecrets { get; set; } = new string[0]; + /// + /// Secrets imported under an explicit environment variable name, each entry in + /// ENV_NAME: SECRET_NAME form. Emitted as ENV_NAME: ${{ secrets.SECRET_NAME }} on the run + /// step's env: block, after . Unlike , the + /// secret name is used as written instead of being derived from the environment variable name, so it + /// reaches secrets that derivation cannot name, and the variable can be any name, such as the + /// Section__Key form of a .NET configuration key. Environment variable names must be unique + /// across both properties. + /// + public string[] ImportSecretsAs { get; set; } = new string[0]; + public bool EnableGitHubToken { get; set; } public GitHubActionsPermissions[] WritePermissions { get; set; } = new GitHubActionsPermissions[0]; @@ -279,6 +290,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection GetImportSecretsAs() + { + foreach (var entry in ImportSecretsAs) + { + Assert.True(entry != null, $"'{nameof(ImportSecretsAs)}' entries must not be null; expected 'ENV_NAME: SECRET_NAME'"); + + var separatorIndex = entry.IndexOf(':'); + Assert.True(separatorIndex > 0, + $"'{nameof(ImportSecretsAs)}' entry '{entry}' must be in 'ENV_NAME: SECRET_NAME' form with a non-empty env name"); + + var variable = entry.Substring(startIndex: 0, separatorIndex); + var secret = entry.Substring(separatorIndex + 1); + Assert.True(!variable.Any(char.IsWhiteSpace), + $"'{nameof(ImportSecretsAs)}' entry '{entry}' has whitespace in its env name; expected 'ENV_NAME: SECRET_NAME'"); + Assert.True(secret.Length == 0 || char.IsWhiteSpace(secret[0]), + $"'{nameof(ImportSecretsAs)}' entry '{entry}' must have a space after the env name's colon; expected 'ENV_NAME: SECRET_NAME'"); + + secret = secret.Trim(); + Assert.True(secret.Length > 0, + $"'{nameof(ImportSecretsAs)}' entry '{entry}' has an empty secret name; expected 'ENV_NAME: SECRET_NAME'"); + Assert.True(!secret.Any(char.IsWhiteSpace), + $"'{nameof(ImportSecretsAs)}' entry '{entry}' has whitespace in its secret name; expected 'ENV_NAME: SECRET_NAME'"); + + yield return (variable, secret); + } + } + + private void ValidateImportSecretsAs() + { + var variables = ImportSecrets.Concat(GetImportSecretsAs().Select(x => x.Variable)) + .Concat(EnableGitHubToken ? new[] { "GITHUB_TOKEN" } : new string[0]).ToList(); + var duplicates = variables.GroupBy(x => x).Where(x => x.Count() > 1).Select(x => x.Key).ToList(); + Assert.True(duplicates.Count == 0, + $"Duplicate env names across '{nameof(ImportSecrets)}', '{nameof(ImportSecretsAs)}' and '{nameof(EnableGitHubToken)}' in workflow '{name}': {duplicates.JoinCommaSpace()}"); + } + protected virtual IEnumerable GetTriggers() { if (OnPushBranches.Length > 0 || diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=import-secrets-as_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=import-secrets-as_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..a402fa4c6 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=import-secrets-as_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] + +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: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + env: + ApiKey: ${{ secrets.API_KEY }} + Apis__NzPost__FunctionKey: ${{ secrets.OPS_API_TESTS_NZPOST_KEY }} + ServiceBus__ConnectionString: ${{ secrets.SERVICE_BUS_CONNECTION_STRING }} + - 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=import-secrets_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=import-secrets_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..e2dca54d5 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=import-secrets_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: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + env: + ApiKey: ${{ secrets.API_KEY }} + - 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..d25070c8b 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -363,6 +363,36 @@ public class TestBuild : FalloutBuild } ); + // Baseline: ImportSecrets alone maps each parameter name to its derived secret name. + yield return + ( + "import-secrets", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + ImportSecrets = new[] { nameof(ApiKey) } + } + ); + + // ImportSecretsAs emits after ImportSecrets, with the secret name used as written and the + // env name taken verbatim (here a .NET configuration key and a secret the derivation can't name). + yield return + ( + "import-secrets-as", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + ImportSecrets = new[] { nameof(ApiKey) }, + ImportSecretsAs = new[] + { + "Apis__NzPost__FunctionKey: OPS_API_TESTS_NZPOST_KEY", + "ServiceBus__ConnectionString: SERVICE_BUS_CONNECTION_STRING" + } + } + ); + // Ordering guard: extra CheckoutWith inputs emit verbatim inside the with: block, after // every typed key (here fetch-depth) and in the order supplied. yield return diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsImportSecretsAsSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsImportSecretsAsSpecs.cs new file mode 100644 index 000000000..91c9ff553 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsImportSecretsAsSpecs.cs @@ -0,0 +1,99 @@ +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.Specs.CI; + +public class GitHubActionsImportSecretsAsSpecs +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("MISSING_COLON")] + [InlineData(": SECRET")] + [InlineData("KEY WITH SPACE: SECRET")] + [InlineData("KEY : SECRET")] + [InlineData("KEY:SECRET")] + [InlineData("KEY:")] + [InlineData("KEY: ")] + [InlineData("KEY: SECRET WITH SPACE")] + public void Malformed_entry_throws(string badEntry) + { + var act = () => GetConfiguration(importSecretsAs: new[] { badEntry }); + + act.Should().Throw(); + } + + [Theory] + [InlineData("Apis__NzPost__FunctionKey: OPS_API_TESTS_NZPOST_KEY")] + [InlineData("KEY: SECRET")] + public void Well_formed_entry_does_not_throw(string goodEntry) + { + var act = () => GetConfiguration(importSecretsAs: new[] { goodEntry }); + + act.Should().NotThrow(); + } + + [Fact] + public void Duplicate_env_name_within_the_property_throws() + { + var act = () => GetConfiguration(importSecretsAs: new[] { "KEY: A", "KEY: B" }); + + act.Should().Throw().WithMessage("*Duplicate env names*KEY*"); + } + + [Fact] + public void Env_name_that_repeats_an_import_secrets_parameter_throws() + { + var act = () => GetConfiguration(importSecrets: new[] { "ApiKey" }, importSecretsAs: new[] { "ApiKey: OTHER_SECRET" }); + + act.Should().Throw().WithMessage("*Duplicate env names*ApiKey*"); + } + + [Fact] + public void Env_name_that_repeats_the_enabled_github_token_throws() + { + var act = () => GetConfiguration(importSecretsAs: new[] { "GITHUB_TOKEN: MY_PAT" }, enableGitHubToken: true); + + act.Should().Throw().WithMessage("*Duplicate env names*GITHUB_TOKEN*"); + } + + [Fact] + public void Github_token_env_name_without_enabling_the_token_does_not_throw() + { + var act = () => GetConfiguration(importSecretsAs: new[] { "GITHUB_TOKEN: MY_PAT" }); + + act.Should().NotThrow(); + } + + [Fact] + public void Same_secret_under_different_env_names_does_not_throw() + { + var act = () => GetConfiguration(importSecretsAs: new[] { "ONE: SHARED_SECRET", "TWO: SHARED_SECRET" }); + + act.Should().NotThrow(); + } + + private static void GetConfiguration(string[] importSecrets = null, string[] importSecretsAs = null, bool enableGitHubToken = false) + { + var build = new ConfigurationGenerationSpecs.TestBuild(); + var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile); + + var attribute = new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) }, + ImportSecrets = importSecrets ?? new string[0], + ImportSecretsAs = importSecretsAs ?? new string[0], + EnableGitHubToken = enableGitHubToken + }; + ((ConfigurationAttributeBase)attribute).Build = build; + + attribute.GetConfiguration(relevantTargets); + } +}