diff --git a/TUnit.Engine.Tests/GitHubArtifactUploaderTests.cs b/TUnit.Engine.Tests/GitHubArtifactUploaderTests.cs new file mode 100644 index 0000000000..3944a4c505 --- /dev/null +++ b/TUnit.Engine.Tests/GitHubArtifactUploaderTests.cs @@ -0,0 +1,56 @@ +using Shouldly; +using TUnit.Engine.Reporters.Html; + +namespace TUnit.Engine.Tests; + +public class GitHubArtifactUploaderTests +{ + private static readonly DateTime FixedUtcNow = new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc); + + [Test] + public void ComputeExpiresAt_Returns_Null_When_No_Retention_Requested() + { + GitHubArtifactUploader.ComputeExpiresAt(null, maxRetentionDays: 90, FixedUtcNow).ShouldBeNull(); + } + + [Test] + [Arguments(0)] + [Arguments(-5)] + public void ComputeExpiresAt_Returns_Null_For_NonPositive_Retention(int retentionDays) + { + GitHubArtifactUploader.ComputeExpiresAt(retentionDays, maxRetentionDays: 90, FixedUtcNow).ShouldBeNull(); + } + + [Test] + public void ComputeExpiresAt_Returns_Rfc3339_Timestamp_Offset_From_Now() + { + var result = GitHubArtifactUploader.ComputeExpiresAt(5, maxRetentionDays: null, FixedUtcNow); + + result.ShouldBe("2026-06-22T12:00:00Z"); + } + + [Test] + public void ComputeExpiresAt_Clamps_To_Repository_Maximum_When_Exceeded() + { + var result = GitHubArtifactUploader.ComputeExpiresAt(120, maxRetentionDays: 90, FixedUtcNow); + + // 90 days, not 120 + result.ShouldBe("2026-09-15T12:00:00Z"); + } + + [Test] + public void ComputeExpiresAt_Does_Not_Clamp_When_Within_Repository_Maximum() + { + var result = GitHubArtifactUploader.ComputeExpiresAt(7, maxRetentionDays: 90, FixedUtcNow); + + result.ShouldBe("2026-06-24T12:00:00Z"); + } + + [Test] + public void ComputeExpiresAt_Ignores_NonPositive_Repository_Maximum() + { + var result = GitHubArtifactUploader.ComputeExpiresAt(10, maxRetentionDays: 0, FixedUtcNow); + + result.ShouldBe("2026-06-27T12:00:00Z"); + } +} diff --git a/TUnit.Engine/Configuration/EnvironmentConstants.cs b/TUnit.Engine/Configuration/EnvironmentConstants.cs index c929bbf1aa..f9717826aa 100644 --- a/TUnit.Engine/Configuration/EnvironmentConstants.cs +++ b/TUnit.Engine/Configuration/EnvironmentConstants.cs @@ -9,6 +9,9 @@ internal static class EnvironmentConstants public const string EnableJUnitReporter = "TUNIT_ENABLE_JUNIT_REPORTER"; public const string GitHubReporterStyle = "TUNIT_GITHUB_REPORTER_STYLE"; + // TUnit-specific: how long (in days) the auto-uploaded HTML report artifact is kept + public const string ArtifactRetentionDays = "TUNIT_ARTIFACT_RETENTION_DAYS"; + // TUnit-specific: Execution public const string ExecutionMode = "TUNIT_EXECUTION_MODE"; public const string MaxParallelTests = "TUNIT_MAX_PARALLEL_TESTS"; @@ -37,6 +40,10 @@ internal static class EnvironmentConstants public const string GitHubRepository = "GITHUB_REPOSITORY"; public const string GitHubRunId = "GITHUB_RUN_ID"; + // Repository/organization maximum artifact retention (set by GitHub on the runner). + // Used to clamp TUNIT_ARTIFACT_RETENTION_DAYS so the API does not reject the request. + public const string GitHubRetentionDays = "GITHUB_RETENTION_DAYS"; + // GitHub Actions context (for CI metadata in reports) public const string GitHubSha = "GITHUB_SHA"; public const string GitHubRef = "GITHUB_REF"; diff --git a/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs b/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs index a06af4f6ef..58ee705c43 100644 --- a/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs +++ b/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs @@ -1,8 +1,10 @@ +using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using TUnit.Engine.Configuration; namespace TUnit.Engine.Reporters.Html; @@ -19,6 +21,7 @@ internal static class GitHubArtifactUploader string filePath, string runtimeToken, string resultsUrl, + int? retentionDays, CancellationToken cancellationToken) { var (workflowRunBackendId, workflowJobRunBackendId) = ExtractBackendIds(runtimeToken); @@ -30,6 +33,7 @@ internal static class GitHubArtifactUploader var origin = new Uri(resultsUrl).GetLeftPart(UriPartial.Authority); var fileName = Path.GetFileName(filePath); + var expiresAt = ComputeExpiresAt(retentionDays, ReadMaxRetentionDays(), DateTime.UtcNow); // Step 1: CreateArtifact (deduplicate name on 409 conflict) var createUrl = $"{origin}/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact"; @@ -49,7 +53,7 @@ internal static class GitHubArtifactUploader _ => $"{nameWithoutExt}-{GetShortJobId(workflowJobRunBackendId)}-{nameAttempt}{ext}", }; - var createBody = BuildCreateArtifactJson(workflowRunBackendId, workflowJobRunBackendId, artifactName); + var createBody = BuildCreateArtifactJson(workflowRunBackendId, workflowJobRunBackendId, artifactName, expiresAt); signedUploadUrl = await RetryAsync(async () => { @@ -147,7 +151,7 @@ private static string GetShortJobId(string jobRunBackendId) : jobRunBackendId; } - private static string BuildCreateArtifactJson(string runId, string jobId, string fileName) + private static string BuildCreateArtifactJson(string runId, string jobId, string fileName, string? expiresAt) { using var ms = new MemoryStream(); using var w = new Utf8JsonWriter(ms); @@ -157,11 +161,47 @@ private static string BuildCreateArtifactJson(string runId, string jobId, string w.WriteString("name", fileName); w.WriteNumber("version", 7); w.WriteString("mime_type", "text/html"); + if (expiresAt is not null) + { + // google.protobuf.Timestamp in proto3 canonical JSON form (RFC 3339, UTC). + w.WriteString("expires_at", expiresAt); + } w.WriteEndObject(); w.Flush(); return Encoding.UTF8.GetString(ms.ToArray()); } + private static int? ReadMaxRetentionDays() + { + var raw = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRetentionDays); + return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var days) && days > 0 + ? days + : null; + } + + /// + /// Translates a requested retention period into the expires_at timestamp the + /// GitHub artifact API expects, clamping to the repository maximum when one is set. + /// Returns when no (valid) retention was requested, leaving the + /// artifact on GitHub's default retention. + /// + internal static string? ComputeExpiresAt(int? retentionDays, int? maxRetentionDays, DateTime utcNow) + { + if (retentionDays is not > 0) + { + return null; + } + + var days = retentionDays.Value; + if (maxRetentionDays is > 0 && days > maxRetentionDays.Value) + { + Console.WriteLine($"Warning: TUNIT_ARTIFACT_RETENTION_DAYS ({days}) exceeds the repository maximum ({maxRetentionDays.Value}); clamping to {maxRetentionDays.Value}."); + days = maxRetentionDays.Value; + } + + return utcNow.AddDays(days).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); + } + private static string BuildFinalizeArtifactJson(string runId, string jobId, string fileName, long size, string sha256Hash) { using var ms = new MemoryStream(); diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 9edf953ec1..456483bd67 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -786,7 +786,8 @@ private async Task TryGitHubIntegrationAsync(string filePath, CancellationToken { try { - artifactId = await GitHubArtifactUploader.UploadAsync(filePath, runtimeToken!, resultsUrl!, cancellationToken); + var retentionDays = ParseRetentionDays(); + artifactId = await GitHubArtifactUploader.UploadAsync(filePath, runtimeToken!, resultsUrl!, retentionDays, cancellationToken); if (artifactId is not null) { @@ -812,4 +813,21 @@ private async Task TryGitHubIntegrationAsync(string filePath, CancellationToken } } } + + private static int? ParseRetentionDays() + { + var raw = Environment.GetEnvironmentVariable(EnvironmentConstants.ArtifactRetentionDays); + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var days) && days > 0) + { + return days; + } + + Console.WriteLine($"Warning: Ignoring invalid {EnvironmentConstants.ArtifactRetentionDays} value '{raw}' (expected a positive integer number of days)."); + return null; + } } diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md index d2dca1ed87..a1b62eb5c0 100644 --- a/docs/docs/guides/html-report.md +++ b/docs/docs/guides/html-report.md @@ -93,6 +93,23 @@ If you prefer not to expose the runtime token, you can upload the report yoursel path: '**/*-report.html' ``` +### Setting Artifact Retention + +By default the uploaded artifact uses your repository's default retention period. To keep +the report for a shorter time (and stay under your Actions storage quota), set the +`TUNIT_ARTIFACT_RETENTION_DAYS` environment variable to the number of days you want: + +```yaml +- name: Run Tests + run: dotnet run --project MyTests + env: + TUNIT_ARTIFACT_RETENTION_DAYS: 5 +``` + +The value must be a positive integer. If it exceeds your repository/organization maximum, +TUnit clamps it to that maximum. This applies only to the automatic upload (Option A); the +manual `upload-artifact` step (Option B) has its own [`retention-days`](https://github.com/actions/upload-artifact#retention-period) input. + ### Viewing the Report After the workflow run completes: