From a520718bef2fae87e7f8f2ff897059eaa1f2a0c1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:54:26 +0100 Subject: [PATCH 1/2] feat: announce run report baseline --- docs/docs/how-to/run-reports.md | 4 ++- .../Engine/PipelineRunReportFactory.cs | 1 + .../Engine/RunReportService.cs | 3 ++ .../Helpers/SpectreResultsPrinter.cs | 8 +++++ .../Models/PipelineRunReport.cs | 5 +++ .../Engine/RunReportTests.cs | 36 +++++++++++++++++++ .../Helpers/SpectreResultsPrinterTests.cs | 2 ++ 7 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/docs/how-to/run-reports.md b/docs/docs/how-to/run-reports.md index 72f50d4dbbb..d3d4da35a3b 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 b2f544d9106..355334f1c28 100644 --- a/src/ModularPipelines/Engine/PipelineRunReportFactory.cs +++ b/src/ModularPipelines/Engine/PipelineRunReportFactory.cs @@ -83,6 +83,7 @@ public PipelineRunReport Create( Start = summary.Start, End = summary.End, TotalDuration = summary.TotalDuration, + PreviousEnd = previousTotalDuration is null ? null : previousReport!.End, PreviousTotalDuration = previousTotalDuration, TotalDurationDelta = previousTotalDuration is null ? null diff --git a/src/ModularPipelines/Engine/RunReportService.cs b/src/ModularPipelines/Engine/RunReportService.cs index 84f94dfd06b..6e6cdbd0a2c 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 92bd5474020..33695f2fe71 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 ed5249a4e49..169c8058591 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 1b27372e23a..1e7d4f3f4e8 100644 --- a/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs +++ b/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs @@ -303,6 +303,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 +1362,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 fb7c04c7a47..84632356891 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] From 0403b73cdec74badac5f5ff43580149b61fd9669 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:07:26 +0100 Subject: [PATCH 2/2] fix: retain module delta baseline time --- src/ModularPipelines/Engine/PipelineRunReportFactory.cs | 4 +++- test/ModularPipelines.UnitTests/Engine/RunReportTests.cs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ModularPipelines/Engine/PipelineRunReportFactory.cs b/src/ModularPipelines/Engine/PipelineRunReportFactory.cs index 355334f1c28..334eb0c923d 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,7 +85,7 @@ public PipelineRunReport Create( Start = summary.Start, End = summary.End, TotalDuration = summary.TotalDuration, - PreviousEnd = previousTotalDuration is null ? null : previousReport!.End, + PreviousEnd = hasDeltaBaseline ? previousReport!.End : null, PreviousTotalDuration = previousTotalDuration, TotalDurationDelta = previousTotalDuration is null ? null diff --git a/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs b/test/ModularPipelines.UnitTests/Engine/RunReportTests.cs index 1e7d4f3f4e8..1301cf35ba8 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)