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:
- Checkout the code
- It must exist on our runner, right? (`actions/checkout@v2)
- 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)
- 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 agithub.ref_type
oftag
and agithub.ref_name
that contains this text. This is also exposed as environment variables. I’ll use a shell script to remove thev
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 thev
, leaving me just the version number.
- 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 thecsproj
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.
- Run
- 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 thesetup-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 valuegithub.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!