diff --git a/docs/docs/how-to/run-reports.md b/docs/docs/how-to/run-reports.md
index 72f50d4dbb..d3d4da35a3 100644
--- a/docs/docs/how-to/run-reports.md
+++ b/docs/docs/how-to/run-reports.md
@@ -32,6 +32,7 @@ builder.ConfigurePipelineOptions(options => options with
The current schema version is available as `PipelineRunReport.CurrentSchemaVersion`. The completed
report is also exposed through `PipelineSummary.RunReport`.
+After a successful write, an information log records the report's resolved path.
Each schema-v2 report has a unique `RunId` plus `RunCorrelation` metadata for the machine and
detected build system. Registering the Git or GitHub integration also adds the available commit,
@@ -67,7 +68,8 @@ CI runs, even when JSON report writing is disabled. It retains the latest 20 rep
newest compatible report to calculate module and total-duration deltas. When a previous duration
exists, the final results table includes a `Δ previous` column. Deltas compare only successful
runs and successful module executions, so failed or timed-out durations do not create false
-regressions on a later run.
+regressions on a later run. A footer below the table identifies the baseline run by its UTC finish
+time.
Add the default history directory to `.gitignore` if you do not want to commit local run data:
diff --git a/src/ModularPipelines/Engine/PipelineRunReportFactory.cs b/src/ModularPipelines/Engine/PipelineRunReportFactory.cs
index b2f544d910..334eb0c923 100644
--- a/src/ModularPipelines/Engine/PipelineRunReportFactory.cs
+++ b/src/ModularPipelines/Engine/PipelineRunReportFactory.cs
@@ -74,6 +74,8 @@ public PipelineRunReport Create(
&& previousReport?.Status == Status.Successful
? previousReport.TotalDuration
: null;
+ var hasDeltaBaseline = previousTotalDuration.HasValue
+ || modules.Any(static module => module.DurationDelta.HasValue);
return new PipelineRunReport
{
@@ -83,6 +85,7 @@ public PipelineRunReport Create(
Start = summary.Start,
End = summary.End,
TotalDuration = summary.TotalDuration,
+ PreviousEnd = hasDeltaBaseline ? previousReport!.End : null,
PreviousTotalDuration = previousTotalDuration,
TotalDurationDelta = previousTotalDuration is null
? null
diff --git a/src/ModularPipelines/Engine/RunReportService.cs b/src/ModularPipelines/Engine/RunReportService.cs
index 84f94dfd06..6e6cdbd0a2 100644
--- a/src/ModularPipelines/Engine/RunReportService.cs
+++ b/src/ModularPipelines/Engine/RunReportService.cs
@@ -250,6 +250,9 @@ await AtomicFileWriter.WriteAllTextAsync(
RunReportJsonSerializer.Serialize(report),
token)
.ConfigureAwait(false);
+ logger.LogInformation(
+ "Run report written to {RunReportPath}",
+ fullPath);
},
ReportWriteTimeout,
cancellationToken,
diff --git a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs
index 92bd547402..33695f2fe7 100644
--- a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs
+++ b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using Microsoft.Extensions.Options;
using ModularPipelines.Engine;
using ModularPipelines.Extensions;
@@ -201,6 +202,13 @@ private static Table CreateModulesTableCore(PipelineSummary pipelineSummary)
AddTotalRow(table, pipelineSummary, showDeltas);
+ if (showDeltas && pipelineSummary.RunReport?.PreviousEnd is { } previousEnd)
+ {
+ var baseline = previousEnd.ToUniversalTime()
+ .ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
+ table.Caption($"[dim]Δ vs run finished {baseline}[/]");
+ }
+
return table;
}
diff --git a/src/ModularPipelines/Models/PipelineRunReport.cs b/src/ModularPipelines/Models/PipelineRunReport.cs
index ed5249a4e4..169c805859 100644
--- a/src/ModularPipelines/Models/PipelineRunReport.cs
+++ b/src/ModularPipelines/Models/PipelineRunReport.cs
@@ -52,6 +52,11 @@ public sealed record PipelineRunReport
///
public TimeSpan TotalDuration { get; init; }
+ ///
+ /// Gets when the previous retained run used as the delta baseline finished.
+ ///
+ public DateTimeOffset? PreviousEnd { get; init; }
+
///
/// Gets the previous retained run's total duration, when available.
///
diff --git a/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs b/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs
index 1b27372e23..1301cf35ba 100644
--- a/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs
+++ b/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs
@@ -272,6 +272,7 @@ await Assert.That(firstReport.Modules.Single(module => module.ModuleTypeName ==
{
await Assert.That(secondSummary.RunReport!.PreviousTotalDuration).IsNull();
await Assert.That(secondReport!.TotalDurationDelta).IsNull();
+ await Assert.That(secondReport.PreviousEnd).IsEqualTo(firstReport!.End);
await Assert.That(secondReport.Modules
.Single(module => module.Status == Status.Successful)
.PreviousDuration)
@@ -303,6 +304,8 @@ public async Task RunHistoryPersistsAndCalculatesDeltasWithoutReportWriting()
{
await Assert.That(firstSummary.RunReport!.PreviousTotalDuration).IsNull();
await Assert.That(secondSummary.RunReport!.PreviousTotalDuration).IsNotNull();
+ await Assert.That(secondSummary.RunReport.PreviousEnd)
+ .IsEqualTo(firstSummary.RunReport.End);
await Assert.That(Directory.GetFiles(historyPath, "*.json")).Count().IsEqualTo(2);
}
}
@@ -1360,6 +1363,40 @@ private static PipelineOptions CreateReportingOptions(string reportPath) =>
},
};
+ [Test]
+ public async Task SuccessfulReportWriteLogsFullPath()
+ {
+ var directory = CreateTemporaryDirectory();
+ var reportPath = Path.Combine(directory, "artifacts", "run-report.json");
+ var log = new StringBuilder();
+ var distributedOptions = OptionsFactory.Create(new DistributedOptions());
+ var commandExecutionCounter = new CommandExecutionCounter();
+ var service = new RunReportService(
+ Mock.Of(),
+ new PipelineRunReportFactory(
+ commandExecutionCounter,
+ new PassthroughSecretObfuscator()),
+ Mock.Of(),
+ OptionsFactory.Create(CreateReportingOptions(reportPath)),
+ distributedOptions,
+ new RoleDetector(distributedOptions),
+ Mock.Of(),
+ commandExecutionCounter,
+ new StringLogger(log));
+
+ try
+ {
+ await service.CompleteAsync(CreateEmptySummary());
+
+ await Assert.That(log.ToString())
+ .Contains($"Run report written to {Path.GetFullPath(reportPath)}");
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
[Test]
public async Task RunReportEnrichersPopulateObfuscatedCorrelation()
{
diff --git a/test/ModularPipelines.UnitTests/Helpers/SpectreResultsPrinterTests.cs b/test/ModularPipelines.UnitTests/Helpers/SpectreResultsPrinterTests.cs
index fb7c04c7a4..8463235689 100644
--- a/test/ModularPipelines.UnitTests/Helpers/SpectreResultsPrinterTests.cs
+++ b/test/ModularPipelines.UnitTests/Helpers/SpectreResultsPrinterTests.cs
@@ -98,6 +98,7 @@ public async Task ModulesTable_ShowsPreviousRunDurationDelta()
{
RunReport = new PipelineRunReport
{
+ PreviousEnd = new DateTimeOffset(2026, 7, 27, 11, 58, 0, TimeSpan.Zero),
TotalDurationDelta = TimeSpan.FromSeconds(2),
Modules =
[
@@ -115,6 +116,7 @@ public async Task ModulesTable_ShowsPreviousRunDurationDelta()
await Assert.That(output).Contains("Δ previous");
await Assert.That(output).Contains("+2s");
+ await Assert.That(output).Contains("Δ vs run finished 2026-07-27 11:58 UTC");
}
[Test]