Skip to content
Open
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
38 changes: 38 additions & 0 deletions docs/docs/how-to/execution-and-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ public class Module2 : Module<string>
}
```

## Exporting the Dependency Graph

Export the resolved graph without executing modules from the command line:

```bash
dotnet run -- --graph mermaid dependency-graph.mmd
dotnet run -- --graph dot dependency-graph.dot
dotnet run -- --graph json dependency-graph.json
```

The path is optional. The defaults are `dependency-graph.mmd`, `dependency-graph.dot`,
and `dependency-graph.json`. Graph nodes include the module category, estimated duration,
and skip status. Conditions that require runtime results or asynchronous work are shown as
unresolved. Edges point from each dependency to the module that depends on it.

Paths containing a directory separator can include `=` directly. For an ambiguous filename
containing `=`, use the explicit path option so it is not treated as host configuration:

```bash
dotnet run -- --graph json --graph-path branch=main.json
```

You can also export programmatically:

```csharp
using ModularPipelines.Enums;

using var builder = Pipeline.CreateBuilder(args);
builder.AddModule<Module2>();

await builder.ExportDependencyGraphAsync(
DependencyGraphFormat.Mermaid,
"dependency-graph.mmd");
```

When the `ModularPipelines.GitHub` integration writes `GITHUB_STEP_SUMMARY`, it includes
the Mermaid dependency flowchart alongside the run Gantt and result table.

### Auto-Registration

When you declare a required dependency, you don't need to explicitly register it:
Expand Down
79 changes: 63 additions & 16 deletions src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text;
using ModularPipelines.Context;
using ModularPipelines.Engine;
using ModularPipelines.Enums;
using ModularPipelines.Interfaces;
using ModularPipelines.Logging;
Expand All @@ -12,43 +13,60 @@ internal class GitHubMarkdownSummaryGenerator : IPipelineGlobalHooks
private const long MaxFileSizeInBytes = 1 * 1024 * 1024; // 1MB

private readonly ISummaryLogger _summaryLogger;
private readonly IDependencyGraphExporter _dependencyGraphExporter;

public GitHubMarkdownSummaryGenerator(ISummaryLogger summaryLogger)
public GitHubMarkdownSummaryGenerator(
ISummaryLogger summaryLogger,
IDependencyGraphExporter dependencyGraphExporter)
{
_summaryLogger = summaryLogger;
_dependencyGraphExporter = dependencyGraphExporter;
}

public Task OnStartAsync(IPipelineContext pipelineContext)
public Task OnPipelineStartAsync(IPipelineContext pipelineContext)
{
return Task.CompletedTask;
}

public async Task OnEndAsync(IPipelineContext pipelineContext, PipelineSummary pipelineSummary)
public async Task OnPipelineEndAsync(
IPipelineContext pipelineContext,
PipelineSummary pipelineSummary)
{
var mermaid = GenerateMermaidSummary(pipelineSummary);
var table = GenerateTableSummary(pipelineSummary);
var exception = GetException(pipelineSummary);

var stepSummaryVariable = pipelineContext.Environment.Variables.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");

var stepSummaryVariable = pipelineContext.Environment.Variables
.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
if (string.IsNullOrEmpty(stepSummaryVariable))
{
return;
}

await WriteFile(pipelineContext, stepSummaryVariable, mermaid, table, exception);
var mermaid = GenerateMermaidSummary(pipelineSummary);
var dependencyGraph = await GenerateDependencyGraphAsync(pipelineSummary).ConfigureAwait(false);
var table = GenerateTableSummary(pipelineSummary);
var exception = GetException(pipelineSummary);

await WriteFile(
pipelineContext,
stepSummaryVariable,
dependencyGraph,
mermaid,
table,
exception);
}

private async Task WriteFile(IPipelineContext pipelineContext, string stepSummaryVariable, string mermaid,
string table, string exception)
private async Task WriteFile(
IPipelineContext pipelineContext,
string stepSummaryVariable,
string dependencyGraph,
string mermaid,
string table,
string exception)
{
var fileInfo = pipelineContext.Files.GetFile(stepSummaryVariable);
var currentFileSize = fileInfo.Exists ? fileInfo.Length : 0;
var contents = $"{mermaid}\n\n{table}\n\n{_summaryLogger.GetOutput()}{exception}";
long newContentSize = Encoding.UTF8.GetByteCount(contents);
var newSize = currentFileSize + newContentSize;
var existingSummary = $"{mermaid}\n\n{table}\n\n{_summaryLogger.GetOutput()}{exception}";
var contents = SelectContentsToAppend(currentFileSize, dependencyGraph, existingSummary);

if (newSize > MaxFileSizeInBytes)
if (contents is null)
{
System.Console.WriteLine("Appending to the GitHub Step Summary would exceed the 1MB file size limit.");
return;
Expand All @@ -57,6 +75,35 @@ private async Task WriteFile(IPipelineContext pipelineContext, string stepSummar
await pipelineContext.Files.GetFile(stepSummaryVariable).AppendAsync(contents);
}

private static string? SelectContentsToAppend(
long currentFileSize,
string dependencyGraph,
string existingSummary)
{
if (currentFileSize + Encoding.UTF8.GetByteCount(existingSummary) > MaxFileSizeInBytes)
{
return null;
}

var contentsWithGraph = $"{dependencyGraph}\n\n{existingSummary}";
return currentFileSize + Encoding.UTF8.GetByteCount(contentsWithGraph) <= MaxFileSizeInBytes
? contentsWithGraph
: existingSummary;
}

private async Task<string> GenerateDependencyGraphAsync(PipelineSummary pipelineSummary)
{
var graph = await _dependencyGraphExporter
.RenderSummaryAsync(DependencyGraphFormat.Mermaid, pipelineSummary)
.ConfigureAwait(false);
return $"""
### Dependency Graph
```mermaid
{graph}
```
""";
}

private static string GetException(PipelineSummary pipelineSummary)
{
var exception = pipelineSummary.Results
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace ModularPipelines.Attributes;
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class DependsOnModulesInCategoryAttribute : DependsOnBaseAttribute
public sealed class DependsOnModulesInCategoryAttribute : PlanningSafeDependsOnBaseAttribute
{
/// <summary>
/// Gets the category to match.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace ModularPipelines.Attributes;
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class DependsOnModulesWithAttributeAttribute<TAttribute> : DependsOnBaseAttribute
public sealed class DependsOnModulesWithAttributeAttribute<TAttribute> : PlanningSafeDependsOnBaseAttribute
where TAttribute : Attribute
{
/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace ModularPipelines.Attributes;
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class DependsOnModulesWithTagAttribute : DependsOnBaseAttribute
public sealed class DependsOnModulesWithTagAttribute : PlanningSafeDependsOnBaseAttribute
{
/// <summary>
/// Gets the tag to match.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace ModularPipelines.Attributes;
/// Runs a module when an environment variable is set or equals an expected value.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class RunIfEnvironmentVariableAttribute : RunIfAllAttribute
public sealed class RunIfEnvironmentVariableAttribute : RunIfAllAttribute, IPlanningConditionAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RunIfEnvironmentVariableAttribute"/> class.
Expand Down Expand Up @@ -44,7 +44,7 @@ public override Task<bool> EvaluateAsync(IPipelineContext context) =>
/// Skips a module when an environment variable is set or equals an expected value.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class SkipIfEnvironmentVariableAttribute : SkipIfAttribute
public sealed class SkipIfEnvironmentVariableAttribute : SkipIfAttribute, IPlanningConditionAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="SkipIfEnvironmentVariableAttribute"/> class.
Expand Down Expand Up @@ -82,7 +82,7 @@ public override Task<bool> EvaluateAsync(IPipelineContext context) =>
/// Runs a module when an environment variable is not set.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class RunIfEnvironmentVariableUnsetAttribute : RunIfAllAttribute
public sealed class RunIfEnvironmentVariableUnsetAttribute : RunIfAllAttribute, IPlanningConditionAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RunIfEnvironmentVariableUnsetAttribute"/> class.
Expand Down Expand Up @@ -111,7 +111,7 @@ public override Task<bool> EvaluateAsync(IPipelineContext context) =>
/// Skips a module when an environment variable is not set.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class SkipIfEnvironmentVariableUnsetAttribute : SkipIfAttribute
public sealed class SkipIfEnvironmentVariableUnsetAttribute : SkipIfAttribute, IPlanningConditionAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="SkipIfEnvironmentVariableUnsetAttribute"/> class.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ModularPipelines.Attributes.Events;

/// <summary>
/// Marks a module registration receiver as safe to invoke during dependency graph planning.
/// </summary>
/// <remarks>
/// Planning occurs before pipeline startup and the receiver is invoked again during execution.
/// Implementations must therefore be deterministic, idempotent, and free of external side effects.
/// </remarks>
public interface IPlanningSafeModuleRegistrationEventReceiver : IModuleRegistrationEventReceiver;
2 changes: 2 additions & 0 deletions src/ModularPipelines/Attributes/IConditionAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ Task<bool> EvaluateAsync(IPipelineContext context, CancellationToken cancellatio
/// </summary>
string ConditionNames { get; }
}

internal interface IPlanningConditionAttribute : IConditionAttribute;
10 changes: 10 additions & 0 deletions src/ModularPipelines/Attributes/IPlanningSafeDependencySelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ModularPipelines.Attributes;

/// <summary>
/// Marks a dependency-selector attribute as safe to construct during dependency graph planning.
/// </summary>
/// <remarks>
/// Implement this interface only when construction is deterministic, idempotent, and free of
/// observable side effects. Built-in selectors are trusted without this marker.
/// </remarks>
public interface IPlanningSafeDependencySelector;
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ModularPipelines.Attributes;
/// Runs a module on any of the selected operating systems.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class RunIfOperatingSystemAttribute : RunIfAllAttribute, IOperatingSystemConditionAttribute
public sealed class RunIfOperatingSystemAttribute : RunIfAllAttribute, IOperatingSystemConditionAttribute, IPlanningConditionAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RunIfOperatingSystemAttribute"/> class.
Expand Down Expand Up @@ -36,7 +36,7 @@ public override Task<bool> EvaluateAsync(IPipelineContext context) =>
/// Skips a module on any of the selected operating systems.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class SkipIfOperatingSystemAttribute : SkipIfAttribute, IOperatingSystemConditionAttribute
public sealed class SkipIfOperatingSystemAttribute : SkipIfAttribute, IOperatingSystemConditionAttribute, IPlanningConditionAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="SkipIfOperatingSystemAttribute"/> class.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ModularPipelines.Attributes;

/// <summary>
/// Base class for dependency predicates that are safe to evaluate during graph planning.
/// </summary>
/// <remarks>
/// Planning-safe predicates must be deterministic and free of observable side effects.
/// Predicates derived directly from <see cref="DependsOnBaseAttribute"/> are deferred until runtime.
/// </remarks>
public abstract class PlanningSafeDependsOnBaseAttribute : DependsOnBaseAttribute;
1 change: 1 addition & 0 deletions src/ModularPipelines/CommandLine/PipelineCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ internal enum PipelineCommand
DryRun,
ListModules,
Validate,
ExportGraph,
}
17 changes: 17 additions & 0 deletions src/ModularPipelines/CommandLine/PipelineCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal sealed class PipelineCommandHandler(
IRegistrationEventExecutor registrationEventExecutor,
IModuleDependencyRegistry dependencyRegistry,
IModuleMetadataRegistry metadataRegistry,
IDependencyGraphExporter dependencyGraphExporter,
IConsoleWriter consoleWriter)
{
private readonly IReadOnlyList<IModule> _modules = modules
Expand All @@ -33,11 +34,27 @@ internal sealed class PipelineCommandHandler(
case PipelineCommand.Validate:
await FinalizeModulesAsync(cancellationToken).ConfigureAwait(false);
return ReportSuccessfulValidation();
case PipelineCommand.ExportGraph:
await ExportGraphAsync(cancellationToken).ConfigureAwait(false);
return CreateSummary();
default:
throw new ArgumentOutOfRangeException(nameof(commandLineOptions));
}
}

private async Task ExportGraphAsync(CancellationToken cancellationToken)
{
await dependencyGraphExporter.ExportAsync(
commandLineOptions.GraphFormat
?? throw new InvalidOperationException("A dependency graph format is required."),
commandLineOptions.GraphPath
?? throw new InvalidOperationException("A dependency graph path is required."),
cancellationToken)
.ConfigureAwait(false);
consoleWriter.LogToConsole(
$"Dependency graph written to {Path.GetFullPath(commandLineOptions.GraphPath)}");
}

private PipelineSummary ListModules()
{
var table = new Table
Expand Down
10 changes: 8 additions & 2 deletions src/ModularPipelines/CommandLine/PipelineCommandLineOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using ModularPipelines.Enums;

namespace ModularPipelines.PipelineCli;

internal sealed record PipelineCommandLineOptions(
Expand All @@ -7,7 +9,9 @@ internal sealed record PipelineCommandLineOptions(
IReadOnlyList<string> TargetModules,
IReadOnlyList<string> SkippedModules,
IReadOnlyList<string> RunOnlyCategories,
IReadOnlyList<string> IgnoreCategories)
IReadOnlyList<string> IgnoreCategories,
DependencyGraphFormat? GraphFormat,
string? GraphPath)
{
public static PipelineCommandLineOptions Empty { get; } = new(
PipelineCommand.Run,
Expand All @@ -16,5 +20,7 @@ internal sealed record PipelineCommandLineOptions(
[],
[],
[],
[]);
[],
null,
null);
}
Loading
Loading