Tutorial: Create a GitHub Action with .NET
Learn how to create a .NET app that can be used as a GitHub Action. GitHub Actions enable workflow automation and composition. With GitHub Actions, you can build, test, and deploy source code from GitHub. Additionally, actions expose the ability to programmatically interact with issues, create pull requests, perform code reviews, and manage branches. For more information on continuous integration with GitHub Actions, see Building and testing .NET.
In this tutorial, you learn how to:
- Prepare a .NET app for GitHub Actions
- Define action inputs and outputs
- Compose a workflow
Prerequisites
- A GitHub account
- The .NET 6 SDK or later
- A .NET integrated development environment (IDE)
- Feel free to use the Visual Studio IDE
The intent of the app
The app in this tutorial performs code metric analysis by:
Scanning and discovering *.csproj and *.vbproj project files.
Analyzing the discovered source code within these projects for:
- Cyclomatic complexity
- Maintainability index
- Depth of inheritance
- Class coupling
- Number of lines of source code
- Approximated lines of executable code
Creating (or updating) a CODE_METRICS.md file.
The app is not responsible for creating a pull request with the changes to the CODE_METRICS.md file. These changes are managed as part of the workflow composition.
References to the source code in this tutorial have portions of the app omitted for brevity. The complete app code is available on GitHub.
Explore the app
The .NET console app uses the CommandLineParser
NuGet package to parse arguments into the ActionInputs
object.
using CommandLine;
namespace DotNet.GitHubAction;
public class ActionInputs
{
string _repositoryName = null!;
string _branchName = null!;
public ActionInputs()
{
if (Environment.GetEnvironmentVariable("GREETINGS") is { Length: > 0 } greetings)
{
Console.WriteLine(greetings);
}
}
[Option('o', "owner",
Required = true,
HelpText = "The owner, for example: \"dotnet\". Assign from `github.repository_owner`.")]
public string Owner { get; set; } = null!;
[Option('n', "name",
Required = true,
HelpText = "The repository name, for example: \"samples\". Assign from `github.repository`.")]
public string Name
{
get => _repositoryName;
set => ParseAndAssign(value, str => _repositoryName = str);
}
[Option('b', "branch",
Required = true,
HelpText = "The branch name, for example: \"refs/heads/main\". Assign from `github.ref`.")]
public string Branch
{
get => _branchName;
set => ParseAndAssign(value, str => _branchName = str);
}
[Option('d', "dir",
Required = true,
HelpText = "The root directory to start recursive searching from.")]
public string Directory { get; set; } = null!;
[Option('w', "workspace",
Required = true,
HelpText = "The workspace directory, or repository root directory.")]
public string WorkspaceDirectory { get; set; } = null!;
static void ParseAndAssign(string? value, Action<string> assign)
{
if (value is { Length: > 0 } && assign is not null)
{
assign(value.Split("/")[^1]);
}
}
}
The preceding action inputs class defines several required inputs for the app to run successfully. The constructor will write the "GREETINGS"
environment variable value, if one is available in the current execution environment. The Name
and Branch
properties are parsed and assigned from the last segment of a "/"
delimited string.
With the defined action inputs class, focus on the Program.cs file.
using System.Text;
using CommandLine;
using DotNet.GitHubAction;
using DotNet.GitHubAction.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using static CommandLine.Parser;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddGitHubActionServices();
using IHost host = builder.Build();
ParserResult<ActionInputs> parser = Default.ParseArguments<ActionInputs>(() => new(), args);
parser.WithNotParsed(
errors =>
{
host.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger("DotNet.GitHubAction.Program")
.LogError("{Errors}", string.Join(
Environment.NewLine, errors.Select(error => error.ToString())));
Environment.Exit(2);
});
await parser.WithParsedAsync(
async options => await StartAnalysisAsync(options, host));
await host.RunAsync();
static async ValueTask StartAnalysisAsync(ActionInputs inputs, IHost host)
{
// Omitted for brevity, here is the pseudo code:
// - Read projects
// - Calculate code metric analytics
// - Write the CODE_METRICS.md file
// - Set the outputs
var updatedMetrics = true;
var title = "Updated 2 projects";
var summary = "Calculated code metrics on two projects.";
// Do the work here...
// Write GitHub Action workflow outputs.
var gitHubOutputFile = Environment.GetEnvironmentVariable("GITHUB_OUTPUT");
if (!string.IsNullOrWhiteSpace(gitHubOutputFile))
{
using StreamWriter textWriter = new(gitHubOutputFile, true, Encoding.UTF8);
textWriter.WriteLine($"updated-metrics={updatedMetrics}");
textWriter.WriteLine($"summary-title={title}");
textWriter.WriteLine($"summary-details={summary}");
}
await ValueTask.CompletedTask;
Environment.Exit(0);
}
The Program
file is simplified for brevity, to explore the full sample source, see Program.cs. The mechanics in place demonstrate the boilerplate code required to use:
External project or package references can be used, and registered with dependency injection. The Get<TService>
is a static local function, which requires the IHost
instance, and is used to resolve required services. With the CommandLine.Parser.Default
singleton, the app gets a parser
instance from the args
. When the arguments are unable to be parsed, the app exits with a non-zero exit code. For more information, see Setting exit codes for actions.
When the args are successfully parsed, the app was called correctly with the required inputs. In this case, a call to the primary functionality StartAnalysisAsync
is made.
To write output values, you must follow the format recognized by GitHub Actions: Setting an output parameter.
Prepare the .NET app for GitHub Actions
GitHub Actions support two variations of app development, either
- JavaScript (optionally TypeScript)
- Docker container (any app that runs on Docker)
The virtual environment where the GitHub Action is hosted may or may not have .NET installed. For information about what is preinstalled in the target environment, see GitHub Actions Virtual Environments. While it's possible to run .NET CLI commands from the GitHub Actions workflows, for a more fully functioning .NET-based GitHub Action, we recommend that you containerize the app. For more information, see Containerize a .NET app.
The Dockerfile
A Dockerfile is a set of instructions to build an image. For .NET applications, the Dockerfile usually sits in the root of the directory next to a solution file.
# Set the base image as the .NET 7.0 SDK (this includes the runtime)
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
# Copy everything and publish the release (publish implicitly restores and builds)
WORKDIR /app
COPY . ./
RUN dotnet publish ./DotNet.GitHubAction/DotNet.GitHubAction.csproj -c Release -o out --no-self-contained
# Label the container
LABEL maintainer="David Pine <david.pine@microsoft.com>"
LABEL repository="https://github.com/dotnet/samples"
LABEL homepage="https://github.com/dotnet/samples"
# Label as GitHub action
LABEL com.github.actions.name="The name of your GitHub Action"
# Limit to 160 characters
LABEL com.github.actions.description="The description of your GitHub Action."
# See branding:
# https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#branding
LABEL com.github.actions.icon="activity"
LABEL com.github.actions.color="orange"
# Relayer the .NET SDK, anew with the build output
FROM mcr.microsoft.com/dotnet/sdk:7.0
COPY --from=build-env /app/out .
ENTRYPOINT [ "dotnet", "/DotNet.GitHubAction.dll" ]
Note
The .NET app in this tutorial relies on the .NET SDK as part of its functionality. The Dockerfile creates a new set of Docker layers, independent from the previous ones. It starts from scratch with the SDK image, and adds the build output from the previous set of layers. For applications that do not require the .NET SDK as part of their functionality, they should rely on just the .NET Runtime instead. This greatly reduces the size of the image.
FROM mcr.microsoft.com/dotnet/runtime:7.0
Warning
Pay close attention to every step within the Dockerfile, as it does differ from the standard Dockerfile created from the "add docker support" functionality. In particular, the last few steps vary by not specifying a new WORKDIR
which would change the path to the app's ENTRYPOINT
.
The preceding Dockerfile steps include:
- Setting the base image from
mcr.microsoft.com/dotnet/sdk:7.0
as the aliasbuild-env
. - Copying the contents and publishing the .NET app:
- The app is published using the
dotnet publish
command.
- The app is published using the
- Applying labels to the container.
- Relayering the .NET SDK image from
mcr.microsoft.com/dotnet/sdk:7.0
- Copying the published build output from the
build-env
. - Defining the entry point, which delegates to
dotnet /DotNet.GitHubAction.dll
.
Tip
The MCR in mcr.microsoft.com
stands for "Microsoft Container Registry", and is Microsoft's syndicated container catalog from the official Docker hub. For more information, see Microsoft syndicates container catalog.
Caution
If you use a global.json file to pin the SDK version, you should explicitly refer to that version in your Dockerfile. For example, if you've used global.json to pin SDK version 5.0.300
, your Dockerfile should use mcr.microsoft.com/dotnet/sdk:5.0.300
. This prevents breaking the GitHub Actions when a new minor revision is released.
Define action inputs and outputs
In the Explore the app section, you learned about the ActionInputs
class. This object represents the inputs for the GitHub Action. For GitHub to recognize that the repository is a GitHub Action, you need to have an action.yml file at the root of the repository.
name: 'The title of your GitHub Action'
description: 'The description of your GitHub Action'
branding:
icon: activity
color: orange
inputs:
owner:
description:
'The owner of the repo. Assign from github.repository_owner. Example, "dotnet".'
required: true
name:
description:
'The repository name. Example, "samples".'
required: true
branch:
description:
'The branch name. Assign from github.ref. Example, "refs/heads/main".'
required: true
dir:
description:
'The root directory to work from. Examples, "path/to/code".'
required: false
default: '/github/workspace'
outputs:
summary-title:
description:
'The title of the code metrics action.'
summary-details:
description:
'A detailed summary of all the projects that were flagged.'
updated-metrics:
description:
'A boolean value, indicating whether or not the action updated metrics.'
runs:
using: 'docker'
image: 'Dockerfile'
args:
- '-o'
- ${{ inputs.owner }}
- '-n'
- ${{ inputs.name }}
- '-b'
- ${{ inputs.branch }}
- '-d'
- ${{ inputs.dir }}
The preceding action.yml file defines:
- The
name
anddescription
of the GitHub Action - The
branding
, which is used in the GitHub Marketplace to help more uniquely identify your action - The
inputs
, which maps one-to-one with theActionInputs
class - The
outputs
, which is written to in theProgram
and used as part of Workflow composition - The
runs
node, which tells GitHub that the app is adocker
application and what arguments to pass to it
For more information, see Metadata syntax for GitHub Actions.
Pre-defined environment variables
With GitHub Actions, you'll get a lot of environment variables by default. For instance, the variable GITHUB_REF
will always contain a reference to the branch or tag that triggered the workflow run. GITHUB_REPOSITORY
has the owner and repository name, for example, dotnet/docs
.
You should explore the pre-defined environment variables and use them accordingly.
Workflow composition
With the .NET app containerized, and the action inputs and outputs defined, you're ready to consume the action. GitHub Actions are not required to be published in the GitHub Marketplace to be used. Workflows are defined in the .github/workflows directory of a repository as YAML files.
# The name of the work flow. Badges will use this name
name: '.NET code metrics'
on:
push:
branches: [ main ]
paths:
- 'github-actions/DotNet.GitHubAction/**' # run on all changes to this dir
- '!github-actions/DotNet.GitHubAction/CODE_METRICS.md' # ignore this file
workflow_dispatch:
inputs:
reason:
description: 'The reason for running the workflow'
required: true
default: 'Manual run'
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v3
- name: 'Print manual run reason'
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
echo 'Reason: ${{ github.event.inputs.reason }}'
- name: .NET code metrics
id: dotnet-code-metrics
uses: dotnet/samples/github-actions/DotNet.GitHubAction@main
env:
GREETINGS: 'Hello, .NET developers!' # ${{ secrets.GITHUB_TOKEN }}
with:
owner: ${{ github.repository_owner }}
name: ${{ github.repository }}
branch: ${{ github.ref }}
dir: ${{ './github-actions/DotNet.GitHubAction' }}
- name: Create pull request
uses: peter-evans/create-pull-request@v4
if: ${{ steps.dotnet-code-metrics.outputs.updated-metrics }} == 'true'
with:
title: '${{ steps.dotnet-code-metrics.outputs.summary-title }}'
body: '${{ steps.dotnet-code-metrics.outputs.summary-details }}'
commit-message: '.NET code metrics, automated pull request.'
Important
For containerized GitHub Actions, you're required to use runs-on: ubuntu-latest
. For more information, see Workflow syntax jobs.<job_id>.runs-on
.
The preceding workflow YAML file defines three primary nodes:
- The
name
of the workflow. This name is also what's used when creating a workflow status badge. - The
on
node defines when and how the action is triggered. - The
jobs
node outlines the various jobs and steps within each job. Individual steps consume GitHub Actions.
For more information, see Creating your first workflow.
Focusing on the steps
node, the composition is more obvious:
steps:
- uses: actions/checkout@v3
- name: 'Print manual run reason'
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
echo 'Reason: ${{ github.event.inputs.reason }}'
- name: .NET code metrics
id: dotnet-code-metrics
uses: dotnet/samples/github-actions/DotNet.GitHubAction@main
env:
GREETINGS: 'Hello, .NET developers!' # ${{ secrets.GITHUB_TOKEN }}
with:
owner: ${{ github.repository_owner }}
name: ${{ github.repository }}
branch: ${{ github.ref }}
dir: ${{ './github-actions/DotNet.GitHubAction' }}
- name: Create pull request
uses: peter-evans/create-pull-request@v4
if: ${{ steps.dotnet-code-metrics.outputs.updated-metrics }} == 'true'
with:
title: '${{ steps.dotnet-code-metrics.outputs.summary-title }}'
body: '${{ steps.dotnet-code-metrics.outputs.summary-details }}'
commit-message: '.NET code metrics, automated pull request.'
The jobs.steps
represents the workflow composition. Steps are orchestrated such that they're sequential, communicative, and composable. With various GitHub Actions representing steps, each having inputs and outputs, workflows can be composed.
In the preceding steps, you can observe:
The repository is checked out.
A message is printed to the workflow log, when manually ran.
A step identified as
dotnet-code-metrics
:uses: dotnet/samples/github-actions/DotNet.GitHubAction@main
is the location of the containerized .NET app in this tutorial.env
creates an environment variable"GREETING"
, which is printed in the execution of the app.with
specifies each of the required action inputs.
A conditional step, named
Create pull request
runs when thedotnet-code-metrics
step specifies an output parameter ofupdated-metrics
with a value oftrue
.
Important
GitHub allows for the creation of encrypted secrets. Secrets can be used within workflow composition, using the ${{ secrets.SECRET_NAME }}
syntax. In the context of a GitHub Action, there is a GitHub token that is automatically populated by default: ${{ secrets.GITHUB_TOKEN }}
. For more information, see Context and expression syntax for GitHub Actions.
Put it all together
The dotnet/samples GitHub repository is home to many .NET sample source code projects, including the app in this tutorial.
The generated CODE_METRICS.md file is navigable. This file represents the hierarchy of the projects it analyzed. Each project has a top-level section, and an emoji that represents the overall status of the highest cyclomatic complexity for nested objects. As you navigate the file, each section exposes drill-down opportunities with a summary of each area. The markdown has collapsible sections as an added convenience.
The hierarchy progresses from:
- Project file to assembly
- Assembly to namespace
- Namespace to named-type
- Each named-type has a table, and each table has:
- Links to line numbers for fields, methods, and properties
- Individual ratings for code metrics
In action
The workflow specifies that on
a push
to the main
branch, the action is triggered to run. When it runs, the Actions tab in GitHub will report the live log stream of its execution. Here is an example log from the .NET code metrics
run:
Performance improvements
If you followed along the sample, you might have noticed that every time this action is used, it will do a docker build for that image. So, every trigger is faced with some time to build the container before running it. Before releasing your GitHub Actions to the marketplace, you should:
- (automatically) Build the Docker image
- Push the docker image to the GitHub Container Registry (or any other public container registry)
- Change the action to not build the image, but to use an image from a public registry.
# Rest of action.yml content removed for readability
# using Dockerfile
runs:
using: 'docker'
image: 'Dockerfile' # Change this line
# using container image from public registry
runs:
using: 'docker'
image: 'docker://ghcr.io/some-user/some-registry' # Starting with docker:// is important!!
For more information, see GitHub Docs: Working with the Container registry.
See also
- .NET Generic Host
- Dependency injection in .NET
- Code metrics values
- Open-source GitHub Action build in .NET with a workflow for building and pushing the docker image automatically.
Next steps
Feedback
https://aka.ms/ContentUserFeedback.
Coming soon: Throughout 2024 we will be phasing out GitHub Issues as the feedback mechanism for content and replacing it with a new feedback system. For more information see:Submit and view feedback for