Coding Stephan

Write to Github Actions summary from DOTNET

Github action runs can have these nice generated summaries, like the one for my winget package index. But how do you set them? And can you do that with C#? Read on to see how you can do that as well.

Winget index job summary

Github Action summaries

Back in 2022 Github released Github Actions job summaries. Which is a nice feature that allows you to not only write stuff to the console, but also to write stuff to the build summary.

Technical explaination

It is actually quite simple, there is a GITHUB_STEP_SUMMARY environment variable, this is the file location of the file where you can write your Github flavoured markdown to, and it will appear in the job summary of your action.

  1. Get the value of the environment variable GITHUB_STEP_SUMMARY
  2. Append to the file, if it exists don’t overwrite, if it does not exist create and start writing.

Github job summary from dotnet

As described above, it is very important to not overwrite the file, and the file may or may not exist prior to writing to it.

Create a new class GithubFileStream original source thanks @tyrrrz

using System.Diagnostics.CodeAnalysis;
namespace YourNamespace.Util;

internal class GithubFileStream(string filePath, FileMode fileMode) : Stream
{
    private readonly List<byte> _buffer = new(1024);
    private readonly Random _random = new();

    [ExcludeFromCodeCoverage]
    public override bool CanRead => false;

    [ExcludeFromCodeCoverage]
    public override bool CanSeek => false;

    [ExcludeFromCodeCoverage]
    public override bool CanWrite => true;

    [ExcludeFromCodeCoverage]
    public override long Length => _buffer.Count;

    [ExcludeFromCodeCoverage]
    public override long Position { get; set; }

    // Backoff and retry if the file is locked
    private FileStream CreateInnerStream()
    {
        for (var retriesRemaining = 10; ; retriesRemaining--)
        {
            try
            {
                return new FileStream(filePath, fileMode);
            }
            catch (IOException) when (retriesRemaining > 0)
            {
                // Variance in delay to avoid overlapping back-offs
                Thread.Sleep(_random.Next(200, 1000));
            }
        }
    }

    public override void Write(byte[] buffer, int offset, int count) =>
        _buffer.AddRange(buffer.Skip(offset).Take(count));

    public override void Flush()
    {
        using var stream = CreateInnerStream();
        stream.Write(_buffer.ToArray(), 0, _buffer.Count);
        _buffer.Clear();
    }

    [ExcludeFromCodeCoverage]
    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        _buffer.Clear();
    }

    [ExcludeFromCodeCoverage]
    public override int Read(byte[] buffer, int offset, int count) =>
        throw new NotSupportedException();

    [ExcludeFromCodeCoverage]
    public override long Seek(long offset, SeekOrigin origin) =>
        throw new NotSupportedException();

    [ExcludeFromCodeCoverage]
    public override void SetLength(long value) =>
        throw new NotSupportedException();
}

Use the GithubFileStream like here, or check out this sample:

var file = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
if (string.IsNullOrEmpty(file))
{
    Console.WriteLine("GITHUB_STEP_SUMMARY environment variable is not set. Skipping summary generation.");
    return;
}

// FileMode.Append is VERY important!!
using var githubStream = new Util.GithubFileStream(file, FileMode.Append);
using var summaryWriter = new StreamWriter(githubStream, System.Text.Encoding.UTF8);

// Write header (which need an extra whiteline after it)
await summaryWriter.WriteLineAsync("# Awesome summary header\r\n");

// Write some text
await summaryWriter.WriteLineAsync("This is just regular text, with emoji support 👌\r\n");

// Write a warning https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
await summaryWriter.WriteLineAsync("> [!WARNING]\r\n> This is a warning written from dotnet\r\n")

// Not sure if flushing is needed, because of the using, but we will do that anyway.
// the GithubFileStream is a **BUFFERED** stream, it only writes on Flush!
await summaryWriter.FlushAsync(cancellationToken);
await summaryWriter.DisposeAsync();

Notes

Be sure to format your markdown correctly. Sometimes you need additional line endings sometimes you don’t. Tables don’t need an extra line ending between rows, but if you want to start a new table it is mandatory.

Job summaries don’t influence each other, so if you mess up the summary from the next step is not affected.

And lastly, job summaries are capped at 1MB, you’ll get an error message otherwise.

Conclusion

Writing job summaries allows you to clearly write what the action did, without the user having to go through all the console log messages. You can even make it better with emojis or alerts. Making it even more clear something really bad has happened.

I’m a big fan of these summaries and I always wanted to do it from dotnet, I knew Github Actions Test Logger was doing something similar so I just checked their code to see how it worked.

What would you put in your github action job summary? Let me know on Linkedin.