Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions TUnit.Engine.Tests/GitHubArtifactUploaderTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
7 changes: 7 additions & 0 deletions TUnit.Engine/Configuration/EnvironmentConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
44 changes: 42 additions & 2 deletions TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -19,6 +21,7 @@ internal static class GitHubArtifactUploader
string filePath,
string runtimeToken,
string resultsUrl,
int? retentionDays,
CancellationToken cancellationToken)
{
var (workflowRunBackendId, workflowJobRunBackendId) = ExtractBackendIds(runtimeToken);
Expand All @@ -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";
Expand All @@ -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 () =>
{
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

/// <summary>
/// Translates a requested retention period into the <c>expires_at</c> timestamp the
/// GitHub artifact API expects, clamping to the repository maximum when one is set.
/// Returns <see langword="null"/> when no (valid) retention was requested, leaving the
/// artifact on GitHub's default retention.
/// </summary>
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();
Expand Down
20 changes: 19 additions & 1 deletion TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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;
}
}
17 changes: 17 additions & 0 deletions docs/docs/guides/html-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading