In my previous article for DotNetCurry, Using GitHub Actions to build .NET projects, I described how .NET developers can take advantage of GitHub Actions to build their projects hosted on GitHub. I pointed out several actions from the GitHub Marketplace that implement common operations for building .NET projects.
However, sooner or later there will be something you want to do as part of your build that you cannot find a suitable action for in the Marketplace. This happened to me when I wanted to include a code coverage report for my .NET project as a check run. I could not find an action for it.
Fortunately, GitHub Actions are easily extensible. The wide selection of actions in the GitHub Marketplace is a testament to that. There are many ways to create a custom action that does exactly what you want. In this article, I’ll show you how to do that using .NET.
Using .NET local tools
.NET tools were first introduced in .NET Core 2.1 as .NET global command line tools, distributed via NuGet and installable and executable via the .NET CLI.
In .NET Core 3.0, an option was added to run them as local tools. As an alternative to installing the tools manually, this allowed the creation of a manifest file in a folder (typically the root folder of a .NET solution) with a list of .NET tools. These can all be installed with a single command and then run from the folder containing the manifest.
By adding the manifest to the code repository, these tools can be used from scripts within the same repository, ensuring that any developer (or build server) can run them reliably. This means that such scripts (or local tools directly) can also be used from GitHub Actions workflows.
.NET tools are basically just console applications packaged and distributed in a specific way. I described the process of developing and publishing such a tool in a previous DotNetCurry article: .NET Core Global Tools – (What are Global Tools, How to Create and Use Them). However, before writing your own .NET tool, you should check the NuGet Gallery to see if there is not already one that does what you need. Be sure to click on the filter icon at the top right of the results page and filter the results by package type to see only .NET tools.
Figure 1: Filter NuGet packages by package type
For my code coverage needs, that’s exactly what happened. I found the ReportGenerator .NET tool that can format the code coverage results generated by .NET CLI in Markdown (among many other formats). I was then able to include the generated code coverage report as a GitHub Actions check run using the GitHub Checks custom action from the Marketplace.
Let us look at the steps that make this work in detail.
Adding a code coverage report as a check run
When you create your .NET test project in .NET 6 or later, it is already fully set up to collect code coverage information when you run your tests. You can verify this by looking at the test project file, which should contain a reference to the coverlet.collector package:
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
To generate a code coverage results file when you run tests, you must add the –collect: “XPlat Code Coverage” argument to the command:
dotnet test --collect:"XPlat Code Coverage"
The command output contains a full path to the generated XML file containing the code coverage report in Cobertura format: coverage.cobertura.xml. This file (or multiple files if you have more than one test project) is the input to the ReportGenerator .NET tool:
dotnet reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./coverage -reporttypes:MarkdownSummary
But before you can run this command, you need to add ReportGenerator as a local tool to our repository:
· First create the manifest file (if you do not have one yet):
dotnet new tool-manifest
· Then install the tool locally:
dotnet tool install dotnet-reportgenerator-globaltool
After that you can run the ReportGenerator tool. And there should be a dotnet-tools.json file in the .config subfolder with the following content:
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-reportgenerator-globaltool": {
"version": "5.1.4",
"commands": [
"reportgenerator"
]
}
}
}
The file specifies the exact version of the tool to be installed. Do not forget to add it to your repository so that it is available for the GitHub Actions runner.
You are now ready to add the necessary steps for the Markdown code coverage report to your GitHub Actions workflow file:
- name: Run tests and generate code coverage XML report
run: dotnet test --logger trx --collect:"XPlat Code Coverage"
- name: Restore local tools
run: dotnet tool restore
- name: Generate code coverage Markdown report
run: dotnet reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./coverage -reporttypes:MarkdownSummary
- name: Create check run with code coverage Markdown report
uses: LouisBrunner/checks-action@v1.2.0
if: always()
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: Code coverage
conclusion: ${{ job.status }}
output: '{"summary":"Code coverage"}'
output_text_description_file: coverage/Summary.md
After you run a workflow with the above steps, a new Code Coverage check run will be listed on the workflow run details page. If you navigate there, you will see the Markdown report created using the ReportGenerator .NET tool:
Figure 2: Code coverage report as a GitHub check run
As you can see, the report contains information about how many Cobertura code coverage files were included, but it does not list those files. Let us try to fix this with a custom GitHub action.
Creating a custom action
GitHub actions can only be run natively in the runner if they are written in JavaScript. If you want to use another programming language, including C#, you need to create a console application and put it in a Docker container.
Unfortunately, the Console App project template in .NET does not have an option to add Docker support, as some other project templates do. So, you need to first create a new console project without Docker support and then add a Dockerfile to it yourself.
Figure 3: ASP. NET Core Web API project template with an option to add Docker support
The console application I want to create is intentionally very simple so I can focus on the specifics of GitHub actions. I want it to return a list of files that match a pattern (in my case **/coverage.cobertura.xml) so that I can include them in the code coverage check run.
Although my console application has only one input argument, it is common for GitHub actions to have many more. In such cases, you should not implement parsing of command line arguments yourself. You should just use one of the many libraries for that and focus on core functionality instead. My favorite library for parsing command lines is CommandLineUtils.
To use it in your console application, install the following NuGet:
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.0.1" />
If you want to use it with the .NET Generic Host to simplify the startup code when using dependency injection and other standard features of .NET, you also need:
<PackageReference Include="McMaster.Extensions.Hosting.CommandLine" Version="4.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
The Program.cs file is then only responsible for initializing the application with the top-level command:
using GitHubAction;
using Microsoft.Extensions.Hosting;
Host.CreateDefaultBuilder()
.RunCommandLineApplicationAsync<AppCommand>(args);
The AppCommand class, passed as a generic argument, contains the rest of the code in a simple application like mine:
[Command(Description = "Finds matching files.")]
public class AppCommand
{
[Required]
[Option(Description = "Required. File pattern.")]
public string Pattern { get; set; } = null!;
public int OnExecute(IConsole console)
{
try
{
var matcher = new Matcher();
matcher.AddInclude(this.Pattern);
var result = matcher.GetResultsInFullPath(Environment.CurrentDirectory);
var filesString = string.Join(
"<br>",
result.Select(path => path.Replace(Environment.CurrentDirectory, string.Empty)));
console.WriteLine($"::set-output name=files::{filesString}");
return 0;
}
catch (Exception ex)
{
console.WriteLine(ex);
return 1;
}
}
}
I do not want to go into too much implementation detail, as that is not the focus of this article. The following should suffice for a basic understanding of the code:
- Pattern is the only (required) command line argument defined by the CommandLineUtils attributes.
- To find the files that match the specified pattern, I use the Matcher class from the Microsoft.Extensions.FileSystemGlobbing NuGet package.
- I separate the files in the output using the <br> HTML markup for new line, which is also supported in Markdown. This way I can keep the whole result in a single line.
- The ::set-output syntax for the output tells GitHub Actions to set the value in an output parameter called files, so it can be referenced from the workflow file.
As mentioned earlier, GitHub Actions require the console application to run inside a Docker container. Since the . NET project template does not create such a container, you will need to add it yourself. The following Dockerfile is a good starting point:
# Build SDK image
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
WORKDIR /app
# Copy everything from the project folder
# to the /app working directory inside this image
COPY . ./
# Build and publish a release into out subfolder
RUN dotnet publish -c Release -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/runtime:6.0
WORKDIR /app
# Copy everything from /app/out folder in compiler image
# to the /app working directory inside this image
COPY --from=build-env /app/out .
# Entrypoint **must** specify absolute path to the executable
# because GitHub actions will pass in custom working directory
ENTRYPOINT ["dotnet", "/app/GitHubAction.dll"]
Do not worry if you are not familiar with Docker. In most cases, all you need to do is modify the GitHubAction.dll file to match the name of your application. The above file tells Docker to do the following:
- build your project in the . NET 6 SDK image,
- copy the build result to the . NET 6 runtime, and
- run the dotnet /app/GitHubAction.dll command.
Now there is only one thing missing to make your console app a custom GitHub action. You need to add an action.yml metadata file with the following information:
- name and description,
- icon and color from a list of available values,
- input and output parameters,
- Dockerfile and input parameter mapping.
Here is what the file for my custom action might look like:
name: "Sample GitHub Action in .NET 6"
description: "Finds matching files"
branding:
icon: file
color: blue
inputs:
pattern:
description: "File pattern."
required: true
outputs:
files:
description: "Matching file paths."
runs:
using: "docker"
image: "Dockerfile"
args:
- "--pattern"
- ${{ inputs.pattern }}
The input and output parameters part can be confusing at first and deserves some additional explanation.
The parameters defined in the inputs and outputs sections are not directly related to your console application. They are primarily for documentation purposes and are also used by editors with support for GitHub Actions workflow files to provide a better editing experience.
How the input parameters are sent to your console application is specified in the args list of the runs section. The items in the list are sent as individual arguments in the order specified. The ${{ inputs.pattern }} syntax is used to refer to the value of a parameter from the inputs section (in this case pattern).
The actual output parameters and their values are defined by the custom action using the ::set-output syntax in its output. Here is what the actual output might look like for my custom action:
::set-output name=files::/file1.xml<br>/file2.xml
If you want to make your action available to other users, you can publish it on GitHub Marketplace. But you do not have to do that to use it from workflows within the same repository. You can simply reference it by providing a path to the folder containing the action (i.e. the action.yml metadata file):
- name: Find code coverage files
id: find-files
uses: ./GitHubAction
with:
pattern: "**/coverage.cobertura.xml"
The workflow step above would run the custom action from the GitHubAction folder within the same repository and pass it **/coverage.cobertura.xml as the value for the pattern input parameter. As a result, GitHub Actions would run the console application with the following command line arguments: –pattern **/coverage.cobertura.xml.
To include the value of the output parameter in the execution of the code coverage check, the corresponding step could be modified as follows:
- name: Create check run for code coverage
uses: LouisBrunner/checks-action@v1.2.0
if: always()
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: Code coverage
conclusion: ${{ job.status }}
output: '{"summary":"${{ steps.find-files.outputs.files }}"}'
output_text_description_file: coverage/Summary.md
Note the changed value for the output parameter: ‘{“summary”:”${{ steps.find-files.outputs.files }}”}’. The summary field in the inline JSON file contains the value of the files output parameter from the find-files step defined above (which invokes our custom action).
The result should be a list of coverage.cobertura.xml files from which the Markdown code coverage report is generated. They are placed in the upper part of the report, as shown in the following figure.
Figure 4: List of XML files in the Code Coverage Report
This should make it easy for you to verify which files are included in the generated report. But more importantly, you can use the custom action code as the basis for your own custom action.
Conclusion
This article has described two ways to run custom .NET code as part of your GitHub Actions workflow. The first takes advantage of .NET local tools and shows you how to run any .NET tool from NuGet, including your own. The second was to write a custom GitHub action and run it from the same repository without publishing it to GitHub Marketplace.
As an added benefit, the .NET local tool and the custom action from this article were used in a GitHub Actions workflow to add a code coverage check run to a build of any .NET project.
This article has been editorially reviewed by Suprotim Agarwal.
C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.
We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).
Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.
Click here to Explore the Table of Contents or Download Sample Chapters!
Was this article worth reading? Share it with fellow developers too. Thanks!
Damir Arh has many years of experience with software development and maintenance; from complex enterprise software projects to modern consumer-oriented mobile applications. Although he has worked with a wide spectrum of different languages, his favorite language remains C#. In his drive towards better development processes, he is a proponent of Test-driven development, Continuous Integration, and Continuous Deployment. He shares his knowledge by speaking at local user groups and conferences, blogging, and writing articles. He is an awarded Microsoft MVP for .NET since 2012.