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