Skip to content

Commit 60e9f39

Browse files
committed
add support for appending generated report to GITHUB_STEP_SUMMARY
1 parent c53f9cf commit 60e9f39

4 files changed

Lines changed: 285 additions & 37 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-FileCopyrightText: 2025 smdn <smdn@smdn.jp>
2+
// SPDX-License-Identifier: MIT
3+
using System;
4+
using System.IO;
5+
using System.Runtime.ExceptionServices;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
using Microsoft.Testing.Platform.OutputDevice;
10+
11+
internal static class GitHubActions {
12+
public const string GITHUB_STEP_SUMMARY = nameof(GITHUB_STEP_SUMMARY);
13+
14+
private static readonly SemaphoreSlim FileAppendSemaphore = new(1, 1);
15+
16+
internal static async Task AppendStepSummaryAsync(
17+
string contents,
18+
Func<IOutputDeviceData, CancellationToken, Task> displayOutputDeviceAsync,
19+
CancellationToken cancellationToken
20+
)
21+
{
22+
if (
23+
Environment.GetEnvironmentVariable(GITHUB_STEP_SUMMARY) is not string stepSummaryFilePath ||
24+
string.IsNullOrEmpty(stepSummaryFilePath)
25+
) {
26+
await displayOutputDeviceAsync(
27+
new WarningMessageOutputDeviceData(
28+
message: $"The environment variable {GITHUB_STEP_SUMMARY} is not set."
29+
),
30+
cancellationToken
31+
).ConfigureAwait(false);
32+
33+
return;
34+
}
35+
36+
try {
37+
#pragma warning disable SA1114
38+
await TryIOAsync(
39+
async ct => {
40+
try {
41+
await FileAppendSemaphore.WaitAsync(ct).ConfigureAwait(false);
42+
43+
await AppendTextAsync(
44+
path: stepSummaryFilePath,
45+
contents: contents,
46+
cancellationToken: ct
47+
).ConfigureAwait(false);
48+
}
49+
finally {
50+
FileAppendSemaphore.Release();
51+
}
52+
},
53+
cancellationToken: cancellationToken
54+
).ConfigureAwait(false);
55+
#pragma warning restore SA1114
56+
}
57+
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) {
58+
await displayOutputDeviceAsync(
59+
new ExceptionOutputDeviceData(exception: ex),
60+
cancellationToken
61+
).ConfigureAwait(false);
62+
}
63+
64+
static async Task AppendTextAsync(
65+
string path,
66+
string contents,
67+
CancellationToken cancellationToken
68+
)
69+
{
70+
using var stream = File.Open(path, FileMode.Append, FileAccess.Write, FileShare.Read);
71+
using var writer = new StreamWriter(stream);
72+
73+
#if SYSTEM_IO_TEXTWRITER_WRITELINEASYNC_CANCELLATIONTOKEN
74+
await writer.WriteLineAsync(contents.AsMemory(), cancellationToken).ConfigureAwait(false);
75+
#else
76+
await writer.WriteLineAsync(contents).ConfigureAwait(false);
77+
#endif
78+
79+
// append blank lines for subsequent appending
80+
#if SYSTEM_IO_TEXTWRITER_WRITELINEASYNC_CANCELLATIONTOKEN
81+
await writer.WriteLineAsync(ReadOnlyMemory<char>.Empty, cancellationToken).ConfigureAwait(false);
82+
#else
83+
await writer.WriteLineAsync().ConfigureAwait(false);
84+
#endif
85+
86+
#if SYSTEM_IO_TEXTWRITER_FLUSHASYNC_CANCELLATIONTOKEN
87+
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
88+
#else
89+
await writer.FlushAsync().ConfigureAwait(false);
90+
#endif
91+
92+
writer.Close();
93+
}
94+
95+
static async Task TryIOAsync(
96+
Func<CancellationToken, Task> actionAsync,
97+
CancellationToken cancellationToken
98+
)
99+
{
100+
const int MaxRetry = 10;
101+
const int Interval = 200;
102+
103+
Exception? caughtException = null;
104+
105+
for (var retry = MaxRetry; retry != 0; retry--) {
106+
try {
107+
await actionAsync(cancellationToken).ConfigureAwait(false);
108+
109+
return; // completed without exception
110+
}
111+
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) {
112+
// hold the exception and try again
113+
caughtException = ex;
114+
}
115+
116+
await Task.Delay(Interval, cancellationToken).ConfigureAwait(false);
117+
}
118+
119+
if (caughtException is not null)
120+
ExceptionDispatchInfo.Capture(caughtException).Throw();
121+
}
122+
}
123+
}

src/Smdn.Extensions.Mtp.LiquidTestReports/Smdn.Extensions.Mtp.LiquidTestReports/LiquidTestReportsConverter.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ internal sealed class LiquidTestReportsConverter :
5757
private readonly Task<bool> isEnabledTask;
5858
private readonly FileInfo? templateFile;
5959
private readonly string? outputFileExtension;
60+
private readonly bool appendGitHubStepSummary;
6061
private readonly Dictionary<string, string> templateParameters = new(Template.NamingConvention.StringComparer);
6162

6263
private SessionUid? sessionUid;
@@ -80,6 +81,7 @@ IOutputDevice outputDevice
8081
IsEnabled = false;
8182
templateFile = null;
8283
outputFileExtension = null;
84+
appendGitHubStepSummary = false;
8385

8486
if (
8587
commandLineOptionsService.TryGetOptionArgumentList(
@@ -112,6 +114,10 @@ out var argsOutputFileExtension
112114
#pragma warning restore SA1003
113115
}
114116

117+
appendGitHubStepSummary = commandLineOptionsService.IsOptionSet(
118+
LiquidTestReportsGeneratorCommandLine.LiquidTestReportsGitHubStepSummaryOptionName
119+
);
120+
115121
if (
116122
commandLineOptionsService.TryGetOptionArgumentList(
117123
LiquidTestReportsGeneratorCommandLine.LiquidTestReportsParameterOptionName,
@@ -213,7 +219,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella
213219
#pragma warning disable CS8604
214220
#endif
215221
// converts the TRX file using LiquidTestReports
216-
var generated = await ConvertAsync(
222+
var generatedContent = await ConvertAsync(
217223
trxFile: fileArtifact.FileInfo,
218224
templateFile: templateFile,
219225
outputFile: outputFile,
@@ -223,7 +229,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella
223229
#pragma warning restore CS8604
224230
#endif
225231

226-
if (!generated || !outputFile.Exists) {
232+
if (string.IsNullOrEmpty(generatedContent) || !outputFile.Exists) {
227233
await outputDevice.DisplayAsync(
228234
this,
229235
new ErrorMessageOutputDeviceData(message: "LiquidTestReports generated no content"),
@@ -243,6 +249,14 @@ await outputDevice.DisplayAsync(
243249
cancellationToken
244250
).ConfigureAwait(false);
245251

252+
if (appendGitHubStepSummary) {
253+
await GitHubActions.AppendStepSummaryAsync(
254+
contents: generatedContent!,
255+
displayOutputDeviceAsync: DisplayOutputDeviceAsync,
256+
cancellationToken: cancellationToken
257+
).ConfigureAwait(false);
258+
}
259+
246260
await messageBus.PublishAsync(
247261
this,
248262
new SessionFileArtifact(
@@ -258,7 +272,17 @@ await messageBus.PublishAsync(
258272
}
259273
}
260274

261-
private async Task<bool> ConvertAsync(
275+
private Task DisplayOutputDeviceAsync(
276+
IOutputDeviceData data,
277+
CancellationToken cancellationToken
278+
)
279+
=> outputDevice.DisplayAsync(
280+
producer: this,
281+
data: data,
282+
cancellationToken: cancellationToken
283+
);
284+
285+
private async Task<string?> ConvertAsync(
262286
FileInfo trxFile,
263287
FileInfo templateFile,
264288
FileInfo outputFile,
@@ -307,7 +331,7 @@ CancellationToken cancellationToken
307331
).ConfigureAwait(false);
308332

309333
if (string.IsNullOrEmpty(generatedReportContent))
310-
return false; // error: generated no content
334+
return null; // error: generated no content
311335

312336
// ensure the directory for the output file exists
313337
if (generatedFile.Directory is DirectoryInfo directory)
@@ -336,7 +360,7 @@ await outputDevice.DisplayAsync(
336360
).ConfigureAwait(false);
337361
}
338362

339-
return true;
363+
return generatedReportContent;
340364
}
341365

342366
private static async Task<(string ReportContent, IList<Exception> GeneratorErrors)> GenerateReportAsync(

src/Smdn.Extensions.Mtp.LiquidTestReports/Smdn.Extensions.Mtp.LiquidTestReports/LiquidTestReportsGeneratorCommandLine.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal sealed class LiquidTestReportsGeneratorCommandLine : ICommandLineOption
1818
private const string LiquidTestReportsOptionNameCommonPrefix = "liquidtr-";
1919
public const string LiquidTestReportsTemplateFilePathOptionName = $"{LiquidTestReportsOptionNameCommonPrefix}template";
2020
public const string LiquidTestReportsOutputFileExtensionOptionName = $"{LiquidTestReportsOptionNameCommonPrefix}output-extension";
21+
public const string LiquidTestReportsGitHubStepSummaryOptionName = $"{LiquidTestReportsOptionNameCommonPrefix}github-step-summary";
2122
public const string LiquidTestReportsParameterOptionName = $"{LiquidTestReportsOptionNameCommonPrefix}parameter";
2223

2324
/// <inheritdoc />
@@ -49,6 +50,12 @@ public IReadOnlyCollection<CommandLineOption> GetCommandLineOptions()
4950
arity: ArgumentArity.ZeroOrOne,
5051
isHidden: false
5152
),
53+
new(
54+
name: LiquidTestReportsGitHubStepSummaryOptionName,
55+
description: $"Append the converted results as a job summary when the environment variable {GitHubActions.GITHUB_STEP_SUMMARY} is set.",
56+
arity: ArgumentArity.Zero,
57+
isHidden: false
58+
),
5259
new(
5360
name: LiquidTestReportsParameterOptionName,
5461
description: "Specify parameters to pass to LiquidTestReports in the format `<name>=<value>`. Within the template file, the specified value can be referenced as `parameters.<name>`.",

0 commit comments

Comments
 (0)