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 System;
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;
using System.Linq;
using System.Threading.Tasks;
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;

using IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((_, services) => services.AddGitHubActionServices())
    .Build();

static TService Get<TService>(IHost host)
    where TService : notnull =>
    host.Services.GetRequiredService<TService>();

var parser = Default.ParseArguments<ActionInputs>(() => new(), args);
parser.WithNotParsed(
    errors =>
    {
        Get<ILoggerFactory>(host)
            .CreateLogger("DotNet.GitHubAction.Program")
            .LogError(
                string.Join(
                    Environment.NewLine, errors.Select(error => error.ToString())));
        
        Environment.Exit(2);
    });

await parser.WithParsedAsync(options => StartAnalysisAsync(options, host));
await host.RunAsync();

static async Task StartAnalysisAsync(ActionInputs inputs, IHost host)
{
    // Omitted for brevity, here is the preudo 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...

    Console.WriteLine($"::set-output name=updated-metrics::{updatedMetrics}");
    Console.WriteLine($"::set-output name=summary-title::{title}");
    Console.WriteLine($"::set-output name=summary-details::{summary}");

    await Task.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)

Since .NET is not natively supported by GitHub Actions, the .NET app needs to be containerized. 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 5.0 SDK (this includes the runtime)
FROM mcr.microsoft.com/dotnet/sdk:5.0 as build-env

# Copy everything and publish the release (publish implicitly restores and builds)
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:5.0
COPY --from=build-env /out .
ENTRYPOINT [ "dotnet", "/DotNet.GitHubAction.dll" ]

Note

The .NET app in this tutorial relies on the .NET SDK as part of its functionality, as such, the highlighted line relayers the .NET SDK anew with the build output. 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:5.0

The preceding Dockerfile steps include:

  • Setting the base image from mcr.microsoft.com/dotnet/sdk:5.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:5.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.

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.

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

    steps:
    - uses: actions/checkout@v2

    - 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@v3.4.1
      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@v2

- 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@v3.4.1
  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 CODE_METRICS.md file represents the hierarchy of the projects it analyzed. Each project has a top-level section, and an emoji the 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 Action log

See also

Next steps