Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/website/05-cicd/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" })]
```

<details>
<summary>Generated output</summary>

```yaml title=".github/workflows/continuous.yml"
- name: 'Run: Test'
run: dotnet fallout Test
env:
Apis__NzPost__FunctionKey: ${{ secrets.OPS_API_TESTS_NZPOST_KEY }}
```

</details>

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:
Expand Down
51 changes: 51 additions & 0 deletions src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ public GitHubActionsAttribute(

public string[] ImportSecrets { get; set; } = new string[0];

/// <summary>
/// Secrets imported under an explicit environment variable name, each entry in
/// <c>ENV_NAME: SECRET_NAME</c> form. Emitted as <c>ENV_NAME: ${{ secrets.SECRET_NAME }}</c> on the run
/// step's <c>env:</c> block, after <see cref="ImportSecrets"/>. Unlike <see cref="ImportSecrets"/>, 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
/// <c>Section__Key</c> form of a .NET configuration key. Environment variable names must be unique
/// across both properties.
/// </summary>
public string[] ImportSecretsAs { get; set; } = new string[0];

public bool EnableGitHubToken { get; set; }

public GitHubActionsPermissions[] WritePermissions { get; set; } = new GitHubActionsPermissions[0];
Expand Down Expand Up @@ -279,6 +290,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection<Executa
$"'{nameof(Env)}' entry '{variable}' must have a space after the key's colon; expected 'KEY: value'");
}

ValidateImportSecretsAs();
ValidateWorkflowDispatchInputs();
ValidateActionReferences();

Expand Down Expand Up @@ -484,12 +496,51 @@ static string GetSecretValue(string secret)
yield return (secret, GetSecretValue(secret));
}

foreach (var (variable, secret) in GetImportSecretsAs())
yield return (variable, $"${{{{ secrets.{secret} }}}}");

if (EnableGitHubToken)
{
yield return ("GITHUB_TOKEN", GetSecretValue("GITHUB_TOKEN"));
}
}

private IEnumerable<(string Variable, string Secret)> 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<GitHubActionsDetailedTrigger> GetTriggers()
{
if (OnPushBranches.Length > 0 ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# ------------------------------------------------------------------------------
# <auto-generated>
#
# 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
#
# </auto-generated>
# ------------------------------------------------------------------------------

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ------------------------------------------------------------------------------
# <auto-generated>
#
# 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
#
# </auto-generated>
# ------------------------------------------------------------------------------

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
30 changes: 30 additions & 0 deletions tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>();
}

[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<ArgumentException>().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<ArgumentException>().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<ArgumentException>().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);
}
}
Loading