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

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@sha256:d32bd65cf5843f413e81f5d917057c82da99737cb1637e905a1a4bc2e7ec6c8d 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@sha256:d32bd65cf5843f413e81f5d917057c82da99737cb1637e905a1a4bc2e7ec6c8d
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 alias build-env.
  • Copying the contents and publishing the .NET app:
  • 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 and description 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 the ActionInputs class
  • The outputs, which is written to in the Program and used as part of Workflow composition
  • The runs node, which tells GitHub that the app is a docker 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:

  1. The repository is checked out.

  2. A message is printed to the workflow log, when manually ran.

  3. 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.
  4. A conditional step, named Create pull request runs when the dotnet-code-metrics step specifies an output parameter of updated-metrics with a value of true.

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:

.NET code metrics - GitHub Actions log

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:

  1. (automatically) Build the Docker image
  2. Push the docker image to the GitHub Container Registry (or any other public container registry)
  3. 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

Next steps