Ken Muse

Distributing Templates with GitHub


Having templates for your company or your personal projects can improve your development life and enable collaboration. In order to take full advantage of this, we need to make the packages we’ve created available to the rest of our team. To do this, we need a package management solution, such as NuGet, Azure Artifacts, or GitHub Packages. Today, we’ll explore using GitHub Packages and creating a GitHub Action workflow that publishes our template.

GitHub Packages is a package management solution that we can use to distributed NuGet, NPM, Maven, and other package types. Packages is supported in every GitHub plan. Every “owner” in GitHub has a packages feed. If you have an organization, the feed exists at that level. If it’s a personal account or non-organization, it’s scoped to the user level. The URL for the NuGet package feed always follows the same format: https://nuget.pkg.github.com/OWNER/index.json, where OWNER is the organization or user handle. For example, my NuGet feed is https://nuget.pkg.github.com/kenmuse/index.json.

Beyond knowing that URL, we just need to create a workflow in our .github/workflows folder to build and publish our packages. A special note on this: consider making the first line of your YAML file a reference to the associated schema. If you’re using RedHat’s YAML VS Code extension, it will ensure you get the correct Intellisense and autocompletion. The code for that is simple:

1#yaml-language-server: $schema=https://json.schemastore.org/github-workflow

The first step in defining this workflow is to understand what events should trigger publication. Should it be on every build? Should it be when a Release is published? Should it happen any time we create a Tag that starts with v? This will define the trigger events in our workflow YAML. A full list of supported events can be found in the documentation. If we wanted this to happen every time a release is published, we might do this:

1#yaml-language-server: $schema=https://json.schemastore.org/github-workflow
2name: Publish Package
3on:
4  release:
5    types:
6      - published

Publishing a package will require the use of the built-in GITHUB_TOKEN. It is already scoped to the current repository, but as a best practice you want to ensure it has the least possible permissions. This is controlled with the ``permissions` key. For our purposes, we just need two:

  • contents: read
    This provides us access to the contents of the repository. We need that for our Action to checkout the code.
  • packages: write
    This allows us to publish our package and provides read/write access to the feed.

By explicitly requesting specific permissions, we restrict the other permissions available to the token. This helps to secure our workflow. Each permission [grants access to specific API calls])( https://docs.github.com/en/rest/overview/permissions-required-for-github-apps). This can be setup at the workflow or job level.

1permissions:
2  contents: read
3  packages: write

Next, we need to setup the job. Whenever possible, configuring the job to use a Linux runner. These are faster and will minimize the consumption of minutes (Linux runners have consume one purchased minute per actual minute). Packaging and publishing templates only requires MSBuild support, we can easily use a Linux runner.

1jobs:
2  package:
3    runs-on: ubuntu-latest

Finally, we define the steps in the workflow. We will need to:

  1. Checkout the code
    It must exist on our runner, right? (`actions/checkout@v2)
  2. Specify the version of dotne
    I recommend always configuring runtimes and tools. This communicates the tools and version numbers being used, and it ensures that the runner has the version you’re testing and developing against. (`actions/setup-dotnet@v2)
  3. Get the tagged version number
    I could use ${{github.run_number}} to create a unique, incrementing patch version for our package if I want to automatically increment. This can be very helpful for CI builds, but is less valuable for publication. Since we’re using Releases, I will tag the release with a specific version for publication in the format v#.#.#. The GitHub context will have a github.ref_type of tag and a github.ref_name that contains this text. This is also exposed as environment variables. I’ll use a shell script to remove the v and create a new environment variable we can use in the next step of our job. To do this, I append a key-value pair to $GITHUB_ENV by using >> (output redirection). If you’re not familiar with Linux, I can reference existing environment variables using this syntax: ${VARIABLE_NAME}. I can also use a special modifier ##* that strips everything until a matched value. In my case, ##*v will match and remove any characters until the v, leaving me just the version number.
  4. Run dotnet pack
    This will build the package. As a rule of thumb, I tend to output any builds to a subfolder in the runner.temp folder to keep build artifacts and source code separate. This helps prevent me from accidentally publishing source code or other artifacts. I like to include the RepositoryUrl and the RepositoryCommit (SHA) from the commit in my packages, so I’ll pass those MSBuild property values on the command line so that they are included. I also use PackageVersion to override any defined VersionPrefix/VersionSuffix that might exist in the csproj file. Note: For CI builds, you can instead pass --version-suffix ci-${{ github.run_number}} to create a prerelease package with a unique, incrementing version. This is a long, multi-line command, so I split it up for readability using the YAML > to fold the content. This condenses newlines and whitespaces.
  5. Publish the package
    The approach for this has changed several times in the last few years. Currently, the recommended approach (thanks to a NuGet update) is to pass --api-key on the command line with the GitHub token. I do not recommend trying to configure they key using an environment variable with the setup-dotnet action. I find that approach usually fails to work correctly. We can use --no-symbols to avoid having NuGet scan for symbol files (PDBs). I use the context value github.repository_owner to dynamically identify the owner.

The final workflow looks like this:

 1#yaml-language-server: $schema=https://json.schemastore.org/github-workflow
 2name: Publish Package
 3on:
 4  release:
 5    types:
 6      - published
 7permissions:
 8  contents: read
 9  packages: write
10jobs:
11  build:
12    runs-on: ubuntu-latest
13    steps:
14    - name: Checkout code
15      uses: actions/checkout@v2
16
17    - name: Configure dotnet
18      uses: actions/setup-dotnet@v2
19      with:
20        dotnet-version: 6.x
21
22    - name: Get tagged version
23      run: echo "TAG_VERSION=${GITHUB_REF_NAME##*v}" >> $GITHUB_ENV
24
25    - name: Create package
26      run: >
27        dotnet pack PnpTemplates.csproj -c Release -o "${{ runner.temp }}/package"
28        -p:RepositoryUrl="${{ github.repositoryUrl }}" -p:RepositoryCommit="${{ github.sha }}"
29        -p:PackageVersion="${{ env.TAG_VERSION }}"        
30    
31    - name: Publish package
32      run: >
33        dotnet nuget push "${{ runner.temp }}/package/*.nupkg"
34        --no-symbols
35        --api-key ${{ github.token }}
36        --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json"        

If you’re wanting to publish CI builds, you can use a slightly modified package creation step. You can even use an if condition to include it in the same file, skipping the step if we’re creating a tagged release. Using conditions can be a great way to allow the same workflow (with multiple triggers) to handle both the CI and release process.

1- name: Untagged Pack
2  if: ${{ !(github.event_name =='release' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) }}
3  run: >
4    dotnet pack PnpTemplates.csproj -c Release -o "${{ runner.temp }}/package"
5    -p:RepositoryUrl="${{ github.repositoryUrl }}" -p:RepositoryCommit="${{ github.sha }}"
6    --version-suffix "ci-${{ github.run_number }}"    

And that’s it. Your package is now published and available to anyone with appropriate permissions from your package source URL. You can add the feed source to your local NuGet environment using dotnet nuget add source to create a feed. For this example, we’ll name the feed “github”. You will need to create a GitHub personal access token (PAT). You will use your GitHub account name as the USERNAME and the PAT as the password. GitHub provides more details about the NuGet registry here. The final command line is:

1dotnet nuget add source https://nuget.pkg.github.com/OWNER/index.json --name github --username USERNAME --password MYPAT

Now you can use dotnet new -i PACKAGENAME to install your template packages from the feed. The template engine will always look at the available feeds to get the latest version when creating a template with dotnet new TEMPLATENAME, ensuring you are always up-to-date.

Have fun experimenting and Happy DevOp’ing!