-
-
Notifications
You must be signed in to change notification settings - Fork 24
Truncate or omit step summary when approaching GitHub's 1 MiB limit #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
8c54cf8
e9d558d
b279a6f
7f89bcd
7cf9546
d17407d
784dc64
97273ce
49bc721
fb88931
f9e273e
0d8d107
be56208
406b4ad
0855cae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| using System.IO; | ||
|
|
||
| namespace GitHubActionsTestLogger.Tests.Utils.Extensions; | ||
|
|
||
| internal static class FileExtensions | ||
| { | ||
| extension(File) | ||
| { | ||
| // Writes 'count' zero bytes to a file without allocating a large buffer. | ||
| // Uses SetLength to expand the file on disk via a single system call. | ||
| public static void WriteAllZeroes(string path, long count) | ||
| { | ||
| using var fs = new FileStream( | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| path, | ||
| FileMode.Create, | ||
| FileAccess.Write, | ||
| FileShare.None, | ||
| bufferSize: 1 | ||
| ); | ||
| fs.SetLength(count); | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Text; | ||
| using System.Threading.Tasks; | ||
| using GitHubActionsTestLogger.Utils; | ||
| using GitHubActionsTestLogger.Utils.Extensions; | ||
|
|
@@ -11,6 +12,10 @@ namespace GitHubActionsTestLogger.GitHub; | |
| // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions | ||
| internal partial class GitHubWorkflow(TextWriter commandWriter, TextWriter summaryWriter) | ||
| { | ||
| // GitHub step summary file size limit (1 MiB) | ||
| // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary | ||
| private const long SummaryFileSizeLimit = 1024 * 1024; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Put this in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in fb88931 — moved to |
||
|
|
||
| private async Task InvokeCommandAsync( | ||
| string command, | ||
| string message, | ||
|
|
@@ -65,8 +70,65 @@ public async Task CreateErrorAnnotationAsync( | |
| await InvokeCommandAsync("error", message, options); | ||
| } | ||
|
|
||
| public async Task CreateWarningAnnotationAsync(string title, string message) | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| { | ||
| await InvokeCommandAsync( | ||
| "warning", | ||
| message, | ||
| new Dictionary<string, string> { ["title"] = title } | ||
| ); | ||
| } | ||
|
|
||
| public async Task CreateSummaryAsync(string content) | ||
| { | ||
| // Try to extract the underlying file path from the summary writer to monitor file size. | ||
| // This works when the writer wraps a ContentionTolerantWriteFileStream (production) | ||
| // or a plain FileStream (tests). | ||
| var detectedFilePath = summaryWriter is StreamWriter sw | ||
| ? sw.BaseStream switch | ||
| { | ||
| ContentionTolerantWriteFileStream cts => cts.FilePath, | ||
| FileStream fs => fs.Name, | ||
| _ => null, | ||
| } | ||
| : null; | ||
|
|
||
| if (detectedFilePath != null) | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| { | ||
| var existingSize = File.Exists(detectedFilePath) | ||
| ? new FileInfo(detectedFilePath).Length | ||
| : 0L; | ||
|
|
||
| var newlineSize = Encoding.UTF8.GetByteCount(Environment.NewLine); | ||
| var contentSize = Encoding.UTF8.GetByteCount(content); | ||
|
|
||
| // Two leading newlines + content + trailing newline | ||
| var totalToWrite = newlineSize * 3 + contentSize; | ||
|
|
||
| if (existingSize + totalToWrite > SummaryFileSizeLimit) | ||
| { | ||
| var availableBytes = (int)(SummaryFileSizeLimit - existingSize - newlineSize * 3); | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
|
|
||
| var truncated = TryTruncateSummary(content, availableBytes); | ||
|
|
||
| if (truncated == null) | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| { | ||
| // Can't produce a legible summary — skip writing entirely | ||
| await CreateWarningAnnotationAsync( | ||
| "GitHub Actions Test Logger", | ||
| BuildSummaryOmittedWarning(isSharedFile: existingSize > 0) | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| await CreateWarningAnnotationAsync( | ||
| "GitHub Actions Test Logger", | ||
| BuildSummaryTruncatedWarning(isSharedFile: existingSize > 0) | ||
| ); | ||
| content = truncated; | ||
| } | ||
| } | ||
|
|
||
| // If the summary file already contains HTML content, we need to first add two newlines | ||
| // in order to switch GitHub's parser from HTML mode back to markdown mode. | ||
| // It's safe to do it unconditionally because, if the file is empty, these newlines | ||
|
|
@@ -78,6 +140,101 @@ public async Task CreateSummaryAsync(string content) | |
| await summaryWriter.WriteLineAsync(content); | ||
| await summaryWriter.FlushAsync(); | ||
| } | ||
|
|
||
| // Attempt to truncate the rendered summary HTML so that it fits within maxBytes. | ||
| // Returns the truncated content, or null if even the minimal legible content won't fit. | ||
| private static string? TryTruncateSummary(string content, int maxBytes) | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| { | ||
| if (maxBytes <= 0) | ||
| return null; | ||
|
|
||
| // Fast path: content already fits | ||
| if (Encoding.UTF8.GetByteCount(content) <= maxBytes) | ||
| return content; | ||
|
|
||
| // HTML pattern that marks the end of a test-group list item in the outer <ul>. | ||
| // Each group closes as: </ul><p></p></li> (inner results list, margin, group item) | ||
| const string groupItemClose = "</ul><p></p></li>"; | ||
|
|
||
| // Suffix to append after a group-item cut to produce valid HTML | ||
| const string groupCutSuffix = "</ul></details>"; | ||
|
|
||
| // Minimum legible content ends right after </table>; | ||
| // just close the <details> element to produce a valid (stats-only) summary. | ||
| const string tableEndTag = "</table>"; | ||
| const string tableCutSuffix = "</details>"; | ||
|
|
||
| var tableEndIndex = content.LastIndexOf(tableEndTag, StringComparison.Ordinal); | ||
| if (tableEndIndex < 0) | ||
| return null; | ||
|
|
||
| var tableContentEnd = tableEndIndex + tableEndTag.Length; | ||
|
|
||
| // Check whether even the minimal (stats-table-only) content fits. | ||
| // Accumulate byte counts incrementally to avoid O(n²) recalculation. | ||
| var tableCutSuffixBytes = Encoding.UTF8.GetByteCount(tableCutSuffix); | ||
| var tableContentBytes = Encoding.UTF8.GetByteCount(content.Substring(0, tableContentEnd)); | ||
| if (tableContentBytes + tableCutSuffixBytes > maxBytes) | ||
| return null; | ||
|
|
||
| // Try to include as many complete test groups as possible | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| var groupCutSuffixBytes = Encoding.UTF8.GetByteCount(groupCutSuffix); | ||
| var bestCutPoint = tableContentEnd; | ||
| var bestCutSuffix = tableCutSuffix; | ||
|
|
||
| var currentBytes = tableContentBytes; | ||
| var searchFrom = tableContentEnd; | ||
| while (true) | ||
| { | ||
| var groupEnd = content.IndexOf(groupItemClose, searchFrom, StringComparison.Ordinal); | ||
| if (groupEnd < 0) | ||
| break; | ||
|
|
||
| var cutPoint = groupEnd + groupItemClose.Length; | ||
|
|
||
| // Count only the bytes of the new segment (incremental accumulation) | ||
| currentBytes += Encoding.UTF8.GetByteCount( | ||
| content.Substring(searchFrom, cutPoint - searchFrom) | ||
| ); | ||
|
|
||
| if (currentBytes + groupCutSuffixBytes > maxBytes) | ||
| break; // This group doesn't fit — stop | ||
|
|
||
| bestCutPoint = cutPoint; | ||
| bestCutSuffix = groupCutSuffix; | ||
| searchFrom = cutPoint; | ||
| } | ||
|
|
||
| return content.Substring(0, bestCutPoint) + bestCutSuffix; | ||
| } | ||
|
|
||
| private static string BuildSummaryTruncatedWarning(bool isSharedFile) | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| { | ||
| var message = | ||
| "The test summary was truncated because it exceeded GitHub's step summary size limit (1 MiB). " | ||
| + "To reduce the summary size, consider excluding passed tests by setting " | ||
| + "`summary-include-passed=false` or skipped tests by setting `summary-include-skipped=false`."; | ||
|
|
||
| if (isSharedFile) | ||
| message += | ||
| " The summary file is shared with other test steps — consider splitting them into separate jobs."; | ||
|
|
||
| return message; | ||
| } | ||
|
|
||
| private static string BuildSummaryOmittedWarning(bool isSharedFile) | ||
|
Tyrrrz marked this conversation as resolved.
Outdated
|
||
| { | ||
| var message = | ||
| "The test summary was omitted because it exceeded GitHub's step summary size limit (1 MiB). " | ||
| + "To reduce the summary size, consider excluding passed tests by setting " | ||
| + "`summary-include-passed=false` or skipped tests by setting `summary-include-skipped=false`."; | ||
|
|
||
| if (isSharedFile) | ||
| message += | ||
| " The summary file is shared with other test steps — consider splitting them into separate jobs."; | ||
|
|
||
| return message; | ||
| } | ||
| } | ||
|
|
||
| internal partial class GitHubWorkflow | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.