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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
*.DotSettings text eol=lf
*.sh text eol=lf

# Verify snapshots are compared with normalized (LF) line endings; keep them LF in the repo.
*.verified.* text eol=lf

###############################################################################
# Set default behavior for command prompt diff.
#
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Fallout.Common.Utilities;
using Fallout.Common.Utilities.Collections;

namespace Fallout.Common.CI.GitHubActions.Configuration;

/// <summary>
/// A user-constructed workflow step injected via <see cref="IConfigureGitHubActions"/>. A non-empty
/// <see cref="Uses"/> renders a marketplace/action step; a non-empty <see cref="Run"/> renders a shell
/// step (a single entry as <c>run: x</c>, multiple as a <c>run: |</c> block scalar). Exactly one of the
/// two must be set — enforced at generation time.
/// </summary>
public class GitHubActionsCustomStep : GitHubActionsStep
{
public string Name { get; set; }
public string Uses { get; set; }
public Dictionary<string, string> With { get; set; } = new Dictionary<string, string>();
public Dictionary<string, string> Env { get; set; } = new Dictionary<string, string>();
Comment thread
avidenic marked this conversation as resolved.
public string If { get; set; }
public string Shell { get; set; }
public string[] Run { get; set; } = new string[0];
public bool? ContinueOnError { get; set; }
public int? TimeoutMinutes { get; set; }
public string Id { get; set; }

public override void Write(CustomFileWriter writer)
{
// The first emitted key carries the '- ' list marker; every later key is a ' ' continuation.
var written = false;

if (!Name.IsNullOrWhiteSpace())
Scalar("name", Name.SingleQuoteYaml()); // single-quoted so a ':' or apostrophe in the name stays valid YAML
if (!Id.IsNullOrWhiteSpace())
Scalar("id", Id);
if (!Uses.IsNullOrWhiteSpace())
Scalar("uses", Uses);
if ((With?.Count ?? 0) > 0)
MapBlock("with", With);
if ((Env?.Count ?? 0) > 0)
MapBlock("env", Env);

var runCount = Run?.Length ?? 0;
if (runCount == 1)
{
Scalar("run", Run[0]);
}
else if (runCount > 1)
{
writer.WriteLine((written ? " " : "- ") + "run: |");
written = true;
using (writer.Indent())
Run.ForEach(x => writer.WriteLine($" {x}"));
}

if (!Shell.IsNullOrWhiteSpace())
Scalar("shell", Shell);
if (!If.IsNullOrWhiteSpace())
Scalar("if", If);
if (ContinueOnError.HasValue)
Scalar("continue-on-error", ContinueOnError.Value ? "true" : "false");
if (TimeoutMinutes.HasValue)
Scalar("timeout-minutes", TimeoutMinutes.Value.ToString(CultureInfo.InvariantCulture));

return;

void Scalar(string key, string value)
{
writer.WriteLine((written ? " " : "- ") + $"{key}: {value}");
Comment thread
avidenic marked this conversation as resolved.
written = true;
}

void MapBlock(string key, Dictionary<string, string> map)
{
writer.WriteLine((written ? " " : "- ") + $"{key}:");
written = true;
// Ordinal sort so multi-entry blocks render deterministically (Dictionary order isn't guaranteed).
using (writer.Indent())
map.OrderBy(x => x.Key, StringComparer.Ordinal).ForEach(x => writer.WriteLine($" {x.Key}: {x.Value}"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
using Fallout.Common.Utilities;

namespace Fallout.Common.CI.GitHubActions.Configuration;

/// <summary>
/// The per-job insertion surface handed to <see cref="IConfigureGitHubActions.ConfigureSteps"/>. Carries
/// the job's identity (<see cref="WorkflowName"/>, <see cref="Image"/>) and a read-only view of the job's
/// built-in steps, and collects the caller's insertions. Generator-constructed; not user-instantiable.
/// </summary>
public class GitHubActionsStepPipeline
{
private readonly Dictionary<GitHubActionsStepPosition, List<GitHubActionsCustomStep>> _inserts =
new Dictionary<GitHubActionsStepPosition, List<GitHubActionsCustomStep>>();

internal GitHubActionsStepPipeline(string workflowName, GitHubActionsImage image, IReadOnlyList<GitHubActionsStep> builtInSteps)
{
WorkflowName = workflowName;
Image = image;
BuiltInSteps = builtInSteps;
}

/// <summary>The name of the workflow this job belongs to (normalized, spaces-to-underscores).</summary>
public string WorkflowName { get; }

/// <summary>The runner image of this job.</summary>
public GitHubActionsImage Image { get; }

/// <summary>A read-only view of the built-in steps already assembled for this job.</summary>
public IReadOnlyList<GitHubActionsStep> BuiltInSteps { get; }

/// <summary>Insert one custom step at <paramref name="position"/>. Multiple inserts at one position render in call order.</summary>
public void Insert(GitHubActionsStepPosition position, GitHubActionsCustomStep step)
Comment thread
avidenic marked this conversation as resolved.
{
Assert.NotNull(step);
if (!_inserts.TryGetValue(position, out var list))
_inserts[position] = list = new List<GitHubActionsCustomStep>();
list.Add(step);
}

/// <summary>Insert several custom steps at <paramref name="position"/>, in enumeration order.</summary>
public void Insert(GitHubActionsStepPosition position, IEnumerable<GitHubActionsCustomStep> steps)
{
Assert.NotNull(steps);
foreach (var step in steps)
Insert(position, step);
}

internal IReadOnlyList<GitHubActionsCustomStep> GetInserts(GitHubActionsStepPosition position)
=> _inserts.TryGetValue(position, out var list) ? list : (IReadOnlyList<GitHubActionsCustomStep>)new GitHubActionsCustomStep[0];

internal IEnumerable<GitHubActionsCustomStep> AllInserts => _inserts.Values.SelectMany(x => x);
}
115 changes: 76 additions & 39 deletions src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,61 +235,98 @@ protected virtual GitHubActionsJob GetJobs(GitHubActionsImage image, IReadOnlyCo
RunsOnLabels = RunsOnLabels,
EnvironmentName = EnvironmentName,
EnvironmentUrl = EnvironmentUrl,
Steps = GetSteps(relevantTargets).ToArray(),
Steps = GetSteps(relevantTargets, image),
Image = image,
TimeoutMinutes = TimeoutMinutes,
ConcurrencyGroup = JobConcurrencyGroup,
ConcurrencyCancelInProgress = JobConcurrencyCancelInProgress
};
}

private IEnumerable<GitHubActionsStep> GetSteps(IReadOnlyCollection<ExecutableTarget> relevantTargets)
private GitHubActionsStep[] GetSteps(IReadOnlyCollection<ExecutableTarget> relevantTargets, GitHubActionsImage image)
{
yield return new GitHubActionsCheckoutStep
{
Submodules = _submodules,
Lfs = _lfs,
FetchDepth = _fetchDepth,
Progress = _progress,
Filter = _filter,
Ref = _ref,
CheckoutWith = CheckoutWith
};

if (CacheKeyFiles.Any())
{
yield return new GitHubActionsCacheStep
{
IncludePatterns = CacheIncludePatterns,
ExcludePatterns = CacheExcludePatterns,
KeyFiles = CacheKeyFiles
};
}

yield return new GitHubActionsRunStep
{
InvokedTargets = InvokedTargets,
Imports = GetImports().ToDictionary(x => x.Key, x => x.Value)
};

var checkout = new GitHubActionsCheckoutStep
{
Submodules = _submodules,
Lfs = _lfs,
FetchDepth = _fetchDepth,
Progress = _progress,
Filter = _filter,
Ref = _ref,
CheckoutWith = CheckoutWith
};

var cache = CacheKeyFiles.Any()
? new GitHubActionsCacheStep
{
IncludePatterns = CacheIncludePatterns,
ExcludePatterns = CacheExcludePatterns,
KeyFiles = CacheKeyFiles
}
: null;

var run = new GitHubActionsRunStep
{
InvokedTargets = InvokedTargets,
Imports = GetImports().ToDictionary(x => x.Key, x => x.Value)
};

var artifacts = new List<GitHubActionsStep>();
if (PublishArtifacts)
{
var artifacts = relevantTargets
var artifactPaths = relevantTargets
.SelectMany(x => x.ArtifactProducts)
.Select(x => (AbsolutePath)x)
// TODO: https://github.com/actions/upload-artifact/issues/11
.Select(x => x.DescendantsAndSelf(y => y.Parent).FirstOrDefault(y => !y.ToString().ContainsOrdinalIgnoreCase("*")))
.Distinct().ToList();

foreach (var artifact in artifacts)
{
yield return new GitHubActionsArtifactStep
{
Name = artifact.ToString().TrimStart(artifact.Parent.ToString()).TrimStart('/', '\\'),
Path = Build.RootDirectory.GetUnixRelativePathTo(artifact),
Condition = PublishCondition
};
}
foreach (var artifact in artifactPaths)
artifacts.Add(new GitHubActionsArtifactStep
{
Name = artifact.ToString().TrimStart(artifact.Parent.ToString()).TrimStart('/', '\\'),
Path = Build.RootDirectory.GetUnixRelativePathTo(artifact),
Condition = PublishCondition
});
}

var builtInSteps = new List<GitHubActionsStep> { checkout };
if (cache != null)
builtInSteps.Add(cache);
builtInSteps.Add(run);
builtInSteps.AddRange(artifacts);
Comment thread
avidenic marked this conversation as resolved.

var pipeline = new GitHubActionsStepPipeline(_name, image, builtInSteps.AsReadOnly());
if (Build is IConfigureGitHubActions configure)
configure.ConfigureSteps(pipeline);
ValidateCustomSteps(pipeline);

var steps = new List<GitHubActionsStep> { checkout };
steps.AddRange(pipeline.GetInserts(GitHubActionsStepPosition.PostCheckout));
if (cache != null)
steps.Add(cache);
steps.AddRange(pipeline.GetInserts(GitHubActionsStepPosition.PreRun));
steps.Add(run);
steps.AddRange(pipeline.GetInserts(GitHubActionsStepPosition.PostRun));
steps.AddRange(artifacts);
steps.AddRange(pipeline.GetInserts(GitHubActionsStepPosition.JobEnd));
return steps.ToArray();
}

private void ValidateCustomSteps(GitHubActionsStepPipeline pipeline)
{
foreach (var step in pipeline.AllInserts)
{
var id = step.Name ?? step.Uses ?? "(run step)";
var hasUses = !step.Uses.IsNullOrWhiteSpace();
var hasRun = step.Run is { } run && run.Any(x => !x.IsNullOrWhiteSpace());

Assert.True(hasUses ^ hasRun,
$"Custom step '{id}' in workflow '{_name}' must set exactly one of '{nameof(GitHubActionsCustomStep.Uses)}' or '{nameof(GitHubActionsCustomStep.Run)}'");
Assert.True((step.With?.Count ?? 0) == 0 || hasUses,
$"Custom step '{id}' in workflow '{_name}' sets '{nameof(GitHubActionsCustomStep.With)}' but no '{nameof(GitHubActionsCustomStep.Uses)}'; 'with:' is only valid on a 'uses:' step");
Assert.True(step.Shell.IsNullOrWhiteSpace() || !hasUses,
$"Custom step '{id}' in workflow '{_name}' sets '{nameof(GitHubActionsCustomStep.Shell)}' on a 'uses:' step; shell applies only to run steps");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Fallout.Common.CI.GitHubActions;

/// <summary>
/// Named insertion points for custom steps, anchored to the always-present checkout and run block so
/// they stay well-defined when the optional cache / artifact steps are absent.
/// </summary>
public enum GitHubActionsStepPosition
{
/// <summary>After checkout, before the cache step (if any).</summary>
PostCheckout,

/// <summary>After the cache step (if any), before the setup-dotnet / restore / <c>dotnet fallout</c> block.</summary>
PreRun,

/// <summary>After the run block, before the built-in artifact upload (if any).</summary>
PostRun,

/// <summary>After the built-in artifact upload — the end of the job.</summary>
JobEnd,
}
15 changes: 15 additions & 0 deletions src/Fallout.Common/CI/GitHubActions/IConfigureGitHubActions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Fallout.Common.CI.GitHubActions.Configuration;

namespace Fallout.Common.CI.GitHubActions;

/// <summary>
/// Implemented by a build to inject custom steps into generated GitHub Actions jobs. The generator calls
/// <see cref="ConfigureSteps"/> once per generated job, with a pipeline scoped to that job — so steps are
/// scoped to a workflow or runner by ordinary branching on <see cref="GitHubActionsStepPipeline.WorkflowName"/>
/// / <see cref="GitHubActionsStepPipeline.Image"/>, with no per-step scoping arrays. The generator stays in
/// sole control of the base step sequence; implementations only insert.
/// </summary>
public interface IConfigureGitHubActions
{
void ConfigureSteps(GitHubActionsStepPipeline pipeline);
}
4 changes: 4 additions & 0 deletions src/Fallout.Common/Fallout.Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<!--<None Remove="execution-plan.html" />-->
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Fallout.Common.Specs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Fallout.Build\Fallout.Build.csproj" />
<ProjectReference Include="..\Fallout.Build.Shared\Fallout.Build.Shared.csproj" />
Expand Down
10 changes: 10 additions & 0 deletions src/Fallout.Utilities/Text/String.Quoting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ public static string SingleQuote(this string str)
return $"'{str?.Replace("'", "\\'")}'";
}

/// <summary>
/// Single-quotes a given string as a YAML flow scalar, escaping an embedded single quote by doubling it
/// (<c>''</c>) per the YAML spec. Prefer this over <see cref="SingleQuote"/> when emitting YAML: the latter
/// backslash-escapes, which is fine for shell/log output but invalid inside a YAML single-quoted scalar.
/// </summary>
public static string SingleQuoteYaml(this string str)
{
return $"'{str?.Replace("'", "''")}'";
}
Comment thread
ITaluone marked this conversation as resolved.

/// <summary>
/// Indicates whether a given string is double-quoted.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- name: 'Full'
id: full
uses: some/action@v1
with:
k: v
env:
E: 1
if: success()
continue-on-error: true
timeout-minutes: 5
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- uses: some/action@v1
with:
alpha: 1
beta: 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- run: |
echo one
echo two
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: 'Deploy: prod'
run: echo hi
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: 'Bob''s step'
run: echo hi
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- run: gci
shell: pwsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: 'Echo'
run: echo hi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- name: 'Setup Node'
uses: actions/setup-node@v4
with:
node-version: 20
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- uses: actions/checkout@v4
Loading
Loading