Skip to content

[GitHubActions] generator: a typed step-injection pipeline (custom steps at named positions) #456

Description

@avidenic

Description

Add one capability to the generator: inject arbitrary workflow steps at chosen positions in a generated job. Today the step sequence is fixed and closed — GitHubActionsAttribute.GetSteps() (src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs:236-284) yields checkout → optional cache → the setup-dotnet / tool-restore / dotnet fallout run block → optional artifacts, with no hook points. GitHubActionsStep (Configuration/GitHubActionsStep.cs) is an empty abstract base, and every concrete step pins a hardcoded uses: (checkout@v6, setup-dotnet@v4, cache@v4, upload-artifact@v5). There is no way to inject a step that runs a marketplace action (github/codeql-action/analyze, docker/build-push-action, aquasecurity/trivy-action) or a custom shell step. GetSteps is private, so even subclassing can't reach it.

Why a typed code hook rather than another attribute property. The recently merged generator features (Env, RunsOnLabels, DefaultShell, CheckoutWith) are all declarative, finite-cardinality scalars, so an attribute property is the right home for each. A step is different in kind: it is a dictionary (with:, env:) plus ordering plus multiple instances at multiple positions, none of which a C# attribute can carry — attribute arguments are limited to primitives, strings, enums, Type, and arrays of those; never IDictionary, objects, or delegates. That ceiling is exactly why a NUKE fork would resort to a reflection + stringly-typed method-name hook, which silently emits an incomplete workflow on any typo / wrong signature, is invisible to rename refactors and find-usages, and breaks under trimming/AOT. Fallout owns the Build base class, so it has a strictly better, compile-checked seam available. The proposal is three pieces.

(a) A public, user-constructible step type.

public sealed class GitHubActionsCustomStep : GitHubActionsStep
{
    public string? Name { get; init; }
    public string? Uses { get; init; }                  // null Uses => pure `run:` step
    public IDictionary<string, string> With { get; init; } = new Dictionary<string, string>();
    public IDictionary<string, string> Env  { get; init; } = new Dictionary<string, string>();
    public string? If { get; init; }
    public string? Shell { get; init; }                 // run steps only
    public string[] Run { get; init; } = [];             // single entry => `run: x`; multiple => `run: |` block scalar
    public bool? ContinueOnError { get; init; }
    public int? TimeoutMinutes { get; init; }
    public string? Id { get; init; }
}

Validation at generation time — fail the build, never emit invalid YAML silently (consistent with the colon assertions proposed for Env and the typed workflow_dispatch inputs):

  • exactly one of Uses or a non-empty Run must be set — both, or neither, is a build error;
  • a non-empty With requires Uses (with: is only valid on uses: steps);
  • Shell is valid only on a run step (Uses == null);
  • a run step that sets no Shell inherits the workflow-level defaults.run.shell (the merged DefaultShell feature) automatically — no special-casing.

A single custom step type covers both uses: and run: steps; a separate script-step type is unnecessary (a null Uses is the run step).

(b) Named positions, anchored to the always-present checkout and run-block so they stay well-defined when cache/artifacts are absent:

public enum GitHubActionsStepPosition
{
    PostCheckout,   // after checkout, before cache
    PreRun,         // after cache (if any), before the setup-dotnet / restore / dotnet fallout block
    PostRun,        // after the run block, before the built-in artifact upload
    JobEnd,         // after the built-in artifact upload — end of the job
}

(c) A typed placement hook — an interface the build implements, invoked once per generated job, with a write-only insertion API and read-only access to that job's built-in steps:

public interface IConfigureGitHubActions
{
    void ConfigureSteps(GitHubActionsStepPipeline pipeline);
}

public sealed class GitHubActionsStepPipeline
{
    // context — which job is being configured
    public string WorkflowName { get; }
    public GitHubActionsImage Image { get; }

    // read-only — inspect the built-in steps already assembled for this job
    public IReadOnlyList<GitHubActionsStep> BuiltInSteps { get; }

    // write — contribute insertions (no remove, no modify)
    public void Insert(GitHubActionsStepPosition position, GitHubActionsCustomStep step);
    public void Insert(GitHubActionsStepPosition position, IEnumerable<GitHubActionsCustomStep> steps);
}

The generator queries Build as IConfigureGitHubActions and, for each generated job, calls ConfigureSteps with a pipeline scoped to that job, then splices the collected insertions in at their positions (multiple insertions at one position render in call order). Because the hook is invoked per job and the pipeline carries the job's identity, one mechanism scopes steps to a workflow (WorkflowName) or a runner (Image) by ordinary if branching — no per-step scoping arrays needed. The pipeline is the only public surface for placement; BuiltInSteps is a read-only view (a build inspects it by pattern-matching the concrete step types — checkout, cache, run, artifact — to adapt to a job's shape; the exact richness of that view is a detail we're happy to follow your lead on), so the generator stays in sole control of the base sequence.

(If you'd rather avoid a new interface, a protected virtual GetSteps or ConfigurePreRunSteps/ConfigurePostRunSteps seams would also work, and we'll PR whichever you prefer — but the interface keeps the generator in control of the base sequence, whereas an overridable GetSteps makes user code re-emit and re-splice the full internal sequence and couples it to internal ordering.)

Relationship to the multi-job / job-DAG work (#324, #336). This hook is designed to compose with a future multi-job generator rather than be reworked by it. Two deliberate choices carry it forward: the hook is invoked per job (today one job per image; under a job DAG, one call per DAG node), and the job identity lives on the pipeline context object, not the interface signature — so that context can later gain a DAG job's key, the target(s) it runs, and its runner without breaking ConfigureSteps(pipeline). Read-only BuiltInSteps lets injection adapt to a job's shape (e.g. a job that already contains a download-artifact step is a downstream DAG node). The new step types a DAG needs — download-artifact and friends — are already expressible here as ordinary Uses custom steps. Job-level concerns (needs:, job outputs:, environment gates) are explicitly out of scope for this step pipeline; they belong to the job model in that work. We'd suggest landing this issue first, as the step-injection substrate the DAG work can build on.

Usage Example

[GitHubActions("security", GitHubActionsImage.UbuntuLatest,
    On = [GitHubActionsTrigger.Push], InvokedTargets = [nameof(Compile)])]
partial class Build : NukeBuild, IConfigureGitHubActions
{
    public void ConfigureSteps(GitHubActionsStepPipeline pipeline)
    {
        if (pipeline.WorkflowName != "security")
            return; // scope these steps to one of several [GitHubActions] workflows

        pipeline.Insert(GitHubActionsStepPosition.PostCheckout, new GitHubActionsCustomStep
        {
            Name = "Setup Node",
            Uses = "actions/setup-node@v4",
            With = new() { ["node-version"] = "20" },
        });

        pipeline.Insert(GitHubActionsStepPosition.PostRun, new GitHubActionsCustomStep
        {
            Name = "Perform CodeQL Analysis",
            Uses = "github/codeql-action/analyze@v3",
            If = "github.ref == 'refs/heads/main'",
        });
    }
}

Resulting YAML:

steps:
  - uses: actions/checkout@v6
  - name: Setup Node               # injected at PostCheckout
    uses: actions/setup-node@v4
    with:
      node-version: 20
  - uses: actions/cache@v4
    # ...
  - name: 'Setup: .NET SDK'
    uses: actions/setup-dotnet@v4
  - name: 'Restore: dotnet tools'
    run: dotnet tool restore
  - name: 'Run: Compile'
    run: dotnet fallout Compile
  - name: Perform CodeQL Analysis  # injected at PostRun
    uses: github/codeql-action/analyze@v3
    if: github.ref == 'refs/heads/main'

Alternative

Hand-edit the generated YAML (overwritten on regen), maintain a separate hand-written workflow alongside the generated one, or fork the generator. A NUKE fork can do the last via a reflection-based hook (a custom-steps type plus stringly-typed pre/post method-name properties), but that is exactly the fragile mechanism this issue avoids. This issue upstreams the capability with a typed, compile-checked mechanism instead of reflection, folding what would otherwise be two step types into one (a null Uses is the run step).

Could you help with a pull-request?

Yes.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requesttarget/2026Targets the 2026 calendar-version line (current). See ADR-0004.

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions