Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ public GitHubActionsAttribute(
public string[] OnWorkflowDispatchRequiredInputs { get; set; } = new string[0];
public string OnCronSchedule { get; set; }

/// <summary>
/// Workflow-level environment variables, each entry in <c>KEY: value</c> form. Emitted once as a
/// top-level <c>env:</c> block (after <c>on:</c>) 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.
/// <para/>
/// Named <c>Env</c> rather than <c>Environment</c> to avoid confusion with the deployment
/// <c>environment:</c> keyword exposed via <see cref="EnvironmentName"/>.
/// </summary>
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];
Expand Down Expand Up @@ -136,11 +146,25 @@ public override CustomFileWriter CreateWriter(StreamWriter streamWriter)

public override ConfigurationEntity GetConfiguration(IReadOnlyCollection<ExecutableTarget> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ------------------------------------------------------------------------------
# <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]

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ------------------------------------------------------------------------------
# <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, 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
33 changes: 33 additions & 0 deletions tests/Fallout.Common.Tests/CI/ConfigurationGenerationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions tests/Fallout.Common.Tests/CI/GitHubActionsEnvValidationTest.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>();
}

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

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