In this article, I’ll walk through a practical example of how to configure CI/CD using GitHub Actions to validate and publish a NuGet package — starting with a minimal useful pipeline and gradually expanding it to fully automated the required processes.
The original of the article can be found in the my repository.
Table of Contents
- Environment, Process, and Goals
- Manual Steps Required for Publishing
- A Brief Overview of GitHub Actions Automation and YAML File Structure
- 1. MVP Pipeline
- 1.1. Initial Configuration
- 1.2. Creating a Pipeline with a Trigger
- 1.3. Adding the Package Build Job
- 1.4. Adding the Package Publishing Job
- 1.5. Result
- 2. Adding a Test Verification Step
- 3. Adding a Check for the Current Project Version
- 4. Adding a Tag with the Version to the Current Release Commit
- 5. Creating a Release in the GitHub Repository
- 6. Managing Job Dependencies and Execution Order
- 7. Final Pipeline
- Conclusion
Environment, Process, and Goals
Let’s assume we’re developing a library using the C#/.NET stack and plan to make it publicly available as a NuGet package. At this stage, we have a .NET solution in a local Git repository. For remote version control, we’re using the GitHub service.
To understand what we’ll be automating later, let’s walk through the manual NuGet package deployment process.
Manual Steps Required for Publishing
Whenever a new version is ready for release, the following steps need to be executed each time to deliver the new NuGet version to users:
- Run unit tests
- Increment the version number in the project configuration file:
<Version>1.0.1</Version> - Merge commits into the main branch and add a
Gittag with the release version pointing to the commit - Build the release version of the library:
dotnet pack --configuration Release - Publish the release version to
nuget.orgfor public access:dotnet nuget push {NUGET_NAME_WITH_VERSION} --api-key {API_KEY} --source https://api.nuget.org/v3/index.json - Create a release in the
GitHubrepository with a description of the changes included in the current version
We’ll take these steps as the requirements to be automated.
A Brief Overview of GitHub Actions Automation and YAML File Structure
GitHub Actions is a CI/CD tool used to automate workflows for building and publishing software. Workflows can include things like managing branches during pull requests, code reviews and merges, as well as building, testing, and publishing results.
Many of the publishing steps are repetitive and routine, and can be automated — optionally with added flexibility via parameters or conditions. In other words, the process can be automated by writing a script in a YAML file, which GitHub interprets as a set of automation instructions.
A YAML file includes the following elements:
- Triggers for launching automation (e.g., a commit to a specific branch or a pull request)
- Environment in which the commands will be run (e.g., OS type and version, or container)
- Command definitions. These are defined as
stepswithin a singlejob. Apipelinecan containmultiple jobs.
You can read more about how GitHub Actions works on the official documentation site.
The documentation for writing workflows is available here.
1. MVP Pipeline
1.1. Initial Configuration
- We’ll use nuget.org as the NuGet package host. If you don’t already have an account, create one.
- Generate an API key in your NuGet account. This will be required to publish your package.
- Add the necessary metadata to the
<PropertyGroup>and<ItemGroup>sections to the*.csprojproject configuration file for publication.
<PropertyGroup>
<!-- Nuget package unique identifier and version -->
<PackageId>Library.UsefulPackage</PackageId>
<Version>1.0.1</Version>
<!-- License information -->
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Author, description, icon, documentation -->
<Authors>Software Developer</Authors>
<Title>Short description</Title>
<Description>Project description</Description>
<PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<!-- Repository reference -->
<RepositoryUrl>https://github.com/software-developer/useful-library</RepositoryUrl>
<!-- Tags describing the project for indexing and searching -->
<PackageTags>dotnet, useful, lib, etc</PackageTags>
</PropertyGroup>
<ItemGroup>
<!-- PropertyGroup necessary references -->
<None Include="..\..\images\logo.png" Pack="true" PackagePath="\"/>
<None Include="..\..\LICENSE" Pack="true" PackagePath="LICENSE"/>
<None Include="..\..\README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
1.2. Creating a Pipeline with a Trigger
Let’s add a ./github/workflows/release-and-publish.yml file to the project repository, specifying the name of the pipeline and the event that triggers it. In our case, it will be a commit to the master branch of the remote repository.
# 1.2. Creating a Pipeline with a Trigger
# Pipeline name
name: Create release and publish NuGet
# Trigger condition. In this case it is a commit into the remote master branch
on:
push:
branches:
- "master"
# Jobs will be added here according to the requirements...
jobs:
To create a minimally functional and useful pipeline, we'll add a job to build the NuGet package and a job to publish the artifact to nuget.org.
1.3. Adding the Package Build Job
From this point on, I'll only show the delta of the changes to the pipeline. The final version can be found at the end of the article.
# 1.3. Adding the Package Build Job
# Unique job identifier that can be used as a reference
create_nuget:
# User-friendly job name for the UI purposes
name: Create NuGet
# Environment definition. Each job is executed in a separate, isolated environment
runs-on: ubuntu-24.04
# Save path to the NuGet directory in the environment variable
env:
NuGetDirectory: ${{ github.workspace}}/nuget
# List of commands to be run sequentially
steps:
# Checkout on a branch commit to access the source code
- name: Checkout repository
uses: actions/checkout@v4
# Install SDK
- name: Setup .NET
uses: actions/setup-dotnet@v4
# Build and pack package
- name: Pack
shell: pwsh
run: dotnet pack .\src\UsefulPackage --configuration Release --output ${{ env.NuGetDirectory }}
# Uploading an artifact to the repository for access from other jobs
- uses: actions/upload-artifact@v4
with:
name: nuget
if-no-files-found: error
retention-days: 7
path: ${{ env.NuGetDirectory }}/*.nupkg
As a result, we’ll have an uploaded artifact stored with the name UsefulPackage.1.0.1.nupkg, where the version number comes from the .csproj file of the project. Don’t forget to increment the version number with every release, as you won’t be able to publish the same version twice on nuget.org.
1.4. Adding the Package Publishing Job
# 1.4. Adding the Package Publishing Job
deploy:
name: Deploy NuGet
runs-on: ubuntu-24.04
# A ready artifact is required before publishing
# The job waits for the create_nuget job to complete
needs: create_nuget
# This jobs runs if create_nuget succeeds
if: success()
# Save path to the NuGet directory in the environment variable
env:
NuGetDirectory: ${{ github.workspace}}/nuget
steps:
# Download the contents of the repository
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: nuget
path: ${{ env.NuGetDirectory }}
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
# Publish the NuGet package using the dotnet utility
- name: Publish NuGet package
shell: pwsh
run: |
foreach($file in (Get-ChildItem "${{ env.NuGetDirectory }}" -Recurse -Include *.nupkg)) {
dotnet nuget push $file --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate
}
In the publishing step, we iterate through the contents of the uploaded artifact storage (including subdirectories), and every file with the *.nupkg extension is uploaded to the nuget.org.
To avoid exposing your NuGet host access key, store it securely in the repository settings and reference it in the pipeline like this: "${{ secrets.NUGET_APIKEY }}".
1.5. Result
In the end, we have a minimally useful pipeline:
In the following sections, we’ll expand the current functionality to fully meet the original requirements:
- Ensure the pipeline only proceeds if tests pass
- Prevent release if the project version is unchanged or incorrectly updated
- Tag the release commit
- Create a GitHub release
2. Adding a Test Verification Step
To ensure that code with failing tests is never published, we’ll add a job to run and check unit tests.
# 2. Adding a Test Verification Step
run_test:
name: Run tests
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
- name: Run tests
shell: pwsh
run: dotnet test --configuration Release .\src\UsefulPackage.UnitTests
3. Adding a Check for the Current Project Version
At this stage, we want to make sure the project version in the *.csproj file is higher than the last released version, which we’ll extract from the tag on the most recent release commit. In other words, this check ensures we didn’t forget to bump the project version before pushing to the master branch.
# 3. Adding a Check for the Current Project Version
check_version:
name: Check project version
runs-on: ubuntu-24.04
outputs:
# The job returns the result of the check in the variable is_valid
# From other jobs the result can be obtained using the expression needs.check_version.outputs.is_valid
is_valid: ${{ steps.compare_versions.outputs.is_valid }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get project version from .csproj
shell: bash
run: |
# Get a project version from the *.csproj file
VERSION=$(grep -oPm1 "(?<=<Version>)[^<]+" ./src/UsefulPackage/UsefulPackage.csproj)
echo "Project version is $VERSION"
# Save the result into the variable VERSION
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Get latest tag
id: tag
run: |
# Get the latest release version according to the latest version tag from the repository
git fetch --tags
LATEST_TAG=$(git tag -l "v*" --sort=-v:refname | head -n 1)
echo "Latest tag: $LATEST_TAG"
# Save the result into the variable LATEST_TAG
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
- name: Compare Strings
id: compare_versions
run: |
# Find the max version by comparing VERSION and LATEST_TAG and save the result into the variable GREATER_VERSION
GREATER_VERSION=$(printf "%s\n%s" "$VERSION" "${LATEST_TAG#v}" | sort -V | tail -n 1)
if [[ "$VERSION" == "$GREATER_VERSION" && "$VERSION" != "${LATEST_TAG#v}" ]]; then
# If the version in the project configuration file is higher than the tag version, then the check is passed.
echo "The new release version is ${LATEST_TAG#v}"
echo "is_valid=true" >> $GITHUB_OUTPUT
else
# Otherwise it is signaled about the error
echo "The project version is not incremented"
echo "is_valid=false" >> $GITHUB_OUTPUT
fi
4. Adding a Tag with the Version to the Current Release Commit
Having a Git tag with the release version is useful for at least three reasons:
- It helps to quickly find and check out the commit for a specific release version in the git log
- This tag is used by the version check in the previous step
- GitHub Releases are linked to the corresponding tag
# 4. Adding a Tag with the Version to the Current Release Commit
tag_and_push:
name: Create and push release tag
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Change git configuration for the current job environment for committing the tag under the author's name and email
- name: Set up Git
run: |
git config --global user.name "${{ secrets.GIT_USER_NAME }}"
git config --global user.email "${{ secrets.GIT_USER_EMAIL }}"
# Using the same code a second time, the best practice is to put it in a separate action
# To simplify the example this step is skipped
- name: Get project version from .csproj
shell: bash
run: |
VERSION=$(grep -oPm1 "(?<=<Version>)[^<]+" ./src/UsefulPackage/UsefulPackage.csproj)
echo "Project version is $VERSION"
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Fetch the latest changes from the remote repository
run: |
git fetch --tags
- name: Create and push tag
run: |
NEW_TAG="v$VERSION"
git tag $NEW_TAG
echo "Tag created: $NEW_TAG"
git push origin $NEW_TAG
5. Creating a Release in the GitHub Repository
Ideally, users should have access to release documentation for each version outlining relevant changes.
# 5. Creating a Release in the GitHub Repository
release:
name: Create release
runs-on: ubuntu-24.04
env:
# Temporary token for workflow authentication to create a release
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create GitHub release
run: |
git fetch --tags
NEW_TAG=$(git describe --tags --abbrev=0 origin/master)
echo "Latest tag on master: $NEW_TAG"
gh release create $NEW_TAG \
--repo="$GITHUB_REPOSITORY" \
--title="${NEW_TAG#v}" \
--generate-notes \
--generate-notes \
--verify-tag \
--latest
6. Managing Job Dependencies and Execution Order
By automating all the requirements, we end up with a sequence of actions:
But the issue is that jobs run independently, regardless of whether other jobs pass or fail. However, in our case, we have clear dependencies and a required order. For instance, there’s no point running the whole workflow if the tests fail or the version is invalid.
To manage dependencies and conditions, we use needs and if tags in jobs. These let us refer to other jobs or their outputs by their unique IDs.
# This job waits for the completion of the specified jobs
needs: [run_test, check_version]
# Create a tag only if the previous job completed successfully and version checking is passed
if: ${{ success() && needs.check_version.outputs.is_valid == 'true' }}
With correctly configured dependencies and execution order, the workflow will look like this:
You can see how I achieved this by checking the final YAML file in the next section.
7. Final Pipeline
# 1.2. Creating a Pipeline with a Trigger
# Pipeline name
name: Create release and publish NuGet
# Trigger condition. In this case it is a commit into the remote master branch
on:
push:
branches:
- "master"
jobs:
# 2. Adding a Test Verification Step
run_test:
name: Run tests
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
- name: Run tests
shell: pwsh
run: dotnet test --configuration Release .\src\UsefulPackage.UnitTests
# 3. Adding a Check for the Current Project Version
check_version:
name: Check project version
runs-on: ubuntu-24.04
outputs:
# The job returns the result of the check in the variable is_valid
# From other jobs the result can be obtained using the expression needs.check_version.outputs.is_valid
is_valid: ${{ steps.compare_versions.outputs.is_valid }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get project version from .csproj
shell: bash
run: |
# Get a project version from the *.csproj file
VERSION=$(grep -oPm1 "(?<=<Version>)[^<]+" ./src/UsefulPackage/UsefulPackage.csproj)
echo "Project version is $VERSION"
# Save the result into the variable VERSION
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Get latest tag
id: tag
run: |
# Get the latest release version according to the latest version tag from the repository
git fetch --tags
LATEST_TAG=$(git tag -l "v*" --sort=-v:refname | head -n 1)
echo "Latest tag: $LATEST_TAG"
# Save the result into the variable LATEST_TAG
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
- name: Compare Strings
id: compare_versions
run: |
# Find the max version by comparing VERSION and LATEST_TAG and save the result into the variable GREATER_VERSION
GREATER_VERSION=$(printf "%s\n%s" "$VERSION" "${LATEST_TAG#v}" | sort -V | tail -n 1)
if [[ "$VERSION" == "$GREATER_VERSION" && "$VERSION" != "${LATEST_TAG#v}" ]]; then
# If the version in the project configuration file is higher than the tag version, then the check is passed
echo "The new release version is ${LATEST_TAG#v}"
echo "is_valid=true" >> $GITHUB_OUTPUT
else
# Otherwise it is signaled about the error
echo "The project version is not incremented"
echo "is_valid=false" >> $GITHUB_OUTPUT
fi
# 4. Adding a Tag with the Version to the Current Release Commit
tag_and_push:
name: Create and push release tag
runs-on: ubuntu-24.04
# Waiting for completion the following jobs...
needs: [run_test, check_version]
# A tag is created after only if the unit tests have passed and the version check has been completed successfully
if: ${{ success() && needs.check_version.outputs.is_valid == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Change git configuration for the current job environment for committing the tag under the author's name and email
- name: Set up Git
run: |
git config --global user.name "${{ secrets.GIT_USER_NAME }}"
git config --global user.email "${{ secrets.GIT_USER_EMAIL }}"
# Using the same code a second time, the best practice is to put it in a separate action
# To simplify the example this step is skipped
- name: Get project version from .csproj
shell: bash
run: |
VERSION=$(grep -oPm1 "(?<=<Version>)[^<]+" ./src/UsefulPackage/UsefulPackage.csproj)
echo "Project version is $VERSION"
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Fetch the latest changes from the remote repository
run: |
git fetch --tags
- name: Create and push tag
run: |
NEW_TAG="v$VERSION"
git tag $NEW_TAG
echo "Tag created: $NEW_TAG"
git push origin $NEW_TAG
# 5. Creating a Release in the GitHub Repository
release:
name: Create release
runs-on: ubuntu-24.04
needs: tag_and_push
if: success()
env:
# Temporary token for workflow authentication to create a release
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create GitHub release
run: |
git fetch --tags
NEW_TAG=$(git describe --tags --abbrev=0 origin/master)
echo "Latest tag on master: $NEW_TAG"
gh release create $NEW_TAG \
--repo="$GITHUB_REPOSITORY" \
--title="${NEW_TAG#v}" \
--generate-notes \
--generate-notes \
--verify-tag \
--latest
# 1.3. Adding the Package Build Job
# Unique job identifier that can be used as a reference
create_nuget:
# User-friendly job name for the UI purposes
name: Create NuGet
# Environment definition. Each job is executed in a separate, isolated environment
runs-on: ubuntu-24.04
# Save path to the NuGet directory in the environment variable
needs: tag_and_push
if: success()
env:
NuGetDirectory: ${{ github.workspace}}/nuget
# List of commands to be run sequentially
steps:
# Checkout on a branch commit to access the source code
- name: Checkout repository
uses: actions/checkout@v4
# Install SDK
- name: Setup .NET
uses: actions/setup-dotnet@v4
# Build and pack package
- name: Pack
shell: pwsh
run: dotnet pack .\src\UsefulPackage --configuration Release --output ${{ env.NuGetDirectory }}
# Uploading an artifact to the repository for access from other jobs
- uses: actions/upload-artifact@v4
with:
name: nuget
if-no-files-found: error
retention-days: 7
path: ${{ env.NuGetDirectory }}/*.nupkg
# 1.4. Adding the Package Publishing Job
deploy:
name: Deploy NuGet
runs-on: ubuntu-24.04
# A ready artifact is required before publishing
# The job waits for the create_nuget job to complete
needs: create_nuget
# This jobs runs if create_nuget succeeds
if: success()
# Save path to the NuGet directory in the environment variable
env:
NuGetDirectory: ${{ github.workspace}}/nuget
steps:
# Download the contents of the repository
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: nuget
path: ${{ env.NuGetDirectory }}
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
# Publish the NuGet package using the dotnet utility
- name: Publish NuGet package
shell: pwsh
run: |
foreach($file in (Get-ChildItem "${{ env.NuGetDirectory }}" -Recurse -Include *.nupkg)) {
dotnet nuget push $file --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate
}
Conclusion
In this article, I shared my experience creating a GitHub Actions pipeline to automate routine NuGet publishing steps, which lets you focus more on the actual functionality and less on repetitive release processes.
Merging new features with the version set in the master branch triggers an automated workflow for validating and publishing the NuGet package to nuget.org, making it available shortly after via NuGet Package Manager or any other NuGet client.
Also, don’t forget to update the release notes section on GitHub after the pipeline completes.




