Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Globalization;
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);
if (!Id.IsNullOrWhiteSpace())
Scalar("id", Id);
if (!Uses.IsNullOrWhiteSpace())
Scalar("uses", Uses);
if (With.Count > 0)
MapBlock("with", With);
if (Env.Count > 0)
MapBlock("env", Env);

if (Run.Length == 1)
{
Scalar("run", Run[0]);
}
else if (Run.Length > 1)
{
writer.WriteLine((written ? " " : "- ") + "run: |");
written = true;
using (writer.Indent())
Run.ForEach(x => writer.WriteLine($" {x}"));
}
Comment thread
avidenic marked this conversation as resolved.

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;
using (writer.Indent())
map.ForEach(x => writer.WriteLine($" {x.Key}: {x.Value}"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;

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.
{
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)
{
foreach (var step in steps)
Insert(position, step);
}
Comment thread
avidenic marked this conversation as resolved.

internal IReadOnlyList<GitHubActionsCustomStep> GetInserts(GitHubActionsStepPosition position)
=> _inserts.TryGetValue(position, out var list) ? list : (IReadOnlyList<GitHubActionsCustomStep>)new GitHubActionsCustomStep[0];
Comment thread
avidenic marked this conversation as resolved.

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.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 || hasUses,
$"Custom step '{id}' in workflow '{_name}' sets '{nameof(GitHubActionsCustomStep.With)}' but no '{nameof(GitHubActionsCustomStep.Uses)}'; 'with:' is only valid on a 'uses:' step");
Comment thread
avidenic marked this conversation as resolved.
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
Loading
Loading