NuGet library CI: versioning from branch name and dual-registry publish
Published: 2026-05-29
Shared .NET libraries need a different pipeline than applications: no Docker image, no
Kubernetes deploy — just dotnet pack and dotnet nuget push. The interesting parts
are version management and the dual-publish pattern: GitLab Package Registry for
internal fast iteration, Artifactory for stable releases consumed by external teams.
Branch naming as version source
We follow the convention that the branch name encodes the package version:
rel/1.4.2 → package version 1.4.2
The CI job extracts it with a shell parameter substitution:
bashVERSION=${CI_COMMIT_BRANCH##*/}
# rel/1.4.2 → 1.4.2
##*/ strips everything up to and including the last /. No external tools, no
git describe, no .csproj edits in CI.
This means the engineer creates the release branch with the correct version name, the
branch triggers the pipeline, and dotnet pack embeds that version in the .nupkg
metadata:
bashdotnet build -p:Version=$VERSION -c Release src/
dotnet pack -p:Version=$VERSION -c Release --no-build src/
Passing -p:Version= at build time overrides <Version> in the .csproj without
modifying the file. The --no-build on pack reuses the already-built output.
Full build job
yamlbuild:
image: registry.example.com/ci-images/dotnet-build:net8
stage: build
script:
- VERSION=${CI_COMMIT_BRANCH##*/}
- dotnet restore -p:Configuration=Release src/
- dotnet build -p:Version=$VERSION -c Release src/
- dotnet pack -p:Version=$VERSION -c Release --no-build src/
artifacts:
expire_in: 1 day
paths:
- "**/*.nupkg"
tags:
- office-dind
The .nupkg files are passed to the push stage via artifacts. expire_in: 1 day
because once pushed to both registries, the file is no longer needed.
Push to GitLab Package Registry
GitLab has a built-in NuGet feed per project. It requires no external credentials —
CI_JOB_TOKEN is a short-lived token issued per job:
yamlpush:
image: registry.example.com/ci-images/dotnet-build:latest
stage: push
script:
# Remove stale 'push' source if it exists from a previous run
- |
if dotnet nuget list source | grep -q "push \["; then
dotnet nuget remove source push
fi
- dotnet nuget add source \
"${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/packages/nuget/index.json" \
--name push \
--username gitlab-ci-token \
--password $CI_JOB_TOKEN \
--store-password-in-clear-text
- dotnet nuget push "**/*.nupkg" --source push
tags:
- office-dind
CI_JOB_TOKEN expires when the job finishes, so --store-password-in-clear-text is
fine here — there is no persistent credential storage.
The grep -q "push \[" guard is needed because nuget.config inside the image may
already have a source named push from a previous build of the image itself. Without
the guard, dotnet nuget add source fails with "source already exists".
Push to Artifactory (stable releases)
Artifactory is the external-facing registry. Only stable releases go there, and only
rel/* branch builds, and only when triggered manually by specific engineers:
yamlpush_artifactory:
image: registry.example.com/ci-images/dotnet-build:latest
stage: push
variables:
# Space-separated list of packages in this repo to publish externally
PACKETS: >-
MyCompany.Api.Schema
MyCompany.Api.Schema.Serialization
MyCompany.Api.Schema.XmlUtils
script:
- |
if dotnet nuget list source | grep -q "push \["; then
dotnet nuget remove source push
fi
- VERSION=${CI_COMMIT_BRANCH##*/}
- dotnet nuget add source \
"https://artifacts.example.com/repository/nuget-releases/index.json" \
--name push \
--username $REGISTRY_NUGET_USERNAME \
--password $REGISTRY_NUGET_PASSWORD \
--store-password-in-clear-text
- |
for pkg in $PACKETS; do
dotnet nuget push "**/${pkg}.${VERSION}.nupkg" --source push
done
rules:
- if: $CI_COMMIT_BRANCH =~ /^rel\/.*/ && $GITLAB_USER_LOGIN =~ /^(alice|bob)$/
when: manual
- if: "$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS"
when: never
tags:
- office-dind
Key decisions:
Per-package push loop — dotnet nuget push "**/*.nupkg" would push every package
in the repo. For a library repo with 3 public packages and 5 internal helper projects,
you don't want the internals on Artifactory. The PACKETS variable lists exactly what
gets published externally.
Manual + user restriction — when: manual prevents accidental publish. The
$GITLAB_USER_LOGIN check is a second gate: even if someone creates a rel/ branch,
only specific people can trigger the Artifactory push button. This is enforced at the
GitLab rules level before Vault/any credentials are touched.
Credentials — $REGISTRY_NUGET_USERNAME and $REGISTRY_NUGET_PASSWORD are GitLab
CI group variables masked and protected (visible only on protected branches).
NuGet source management in the build image
The build image has a nuget.config that points at the internal NuGet proxy (Artifactory
or GitLab group feed) for dotnet restore:
xml<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="internal" value="https://artifacts.example.com/repository/nuget-group/index.json" />
</packageSources>
<packageSourceCredentials>
<internal>
<add key="Username" value="%NUGET_USERNAME%" />
<add key="ClearTextPassword" value="%NUGET_PASSWORD%" />
</internal>
</packageSourceCredentials>
</configuration>
%NUGET_USERNAME% / %NUGET_PASSWORD% are environment variables baked into the image
via build args. This means dotnet restore just works in CI without any source
configuration in the pipeline YAML — the image already knows where packages live.
Workflow rules for library pipelines
Library repos don't deploy to Kubernetes, so the workflow is simpler:
yamlworkflow:
rules:
- if: $CI_MERGE_REQUEST_ID
- if: $CI_COMMIT_BRANCH == "dev"
- if: $CI_COMMIT_BRANCH =~ /^rel\/.*/
- if: "$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS"
when: never
stages:
- build
- push
No sonarqube, deploy, or autotests stages. The .gitlab-ci.yml for a library
repo is 5 lines of variable overrides and one include:.
Versioning edge case: re-releasing a version
If a bug is found in 1.4.2 after it's been pushed to Artifactory, you can't push
1.4.2 again (Artifactory rejects it by default). Options:
- Push
1.4.3with the fix (preferred). - Enable "Allow re-deployment" for the Artifactory repo (not recommended for stable releases — consumers might get a different package for the same version).
- Manually delete the broken version from Artifactory via the UI, then re-push.
We go with option 1. The branch rel/1.4.3 is created from the same commit as
rel/1.4.2 plus the fix commit.
What can go wrong
dotnet nuget add source fails with "source already exists".
The build image may have a source named push baked in. The if dotnet nuget list source | grep -q "push \[" guard handles this — make sure it's in every push job.
Wrong version embedded in the package.
If VERSION=${CI_COMMIT_BRANCH##*/} runs on a branch like feature/my-thing, you get my-thing as the version, which is not a valid semver. Add a rules block to restrict the build+pack stage to rel/* branches only.
Artifactory rejects the push with 400 "artifact already exists".
Re-deploying to a release repository is blocked by default. Either increment the version (create rel/1.4.3) or enable "Allow Re-deployment" in Artifactory settings for that specific repo — not recommended for stable releases.
CI_JOB_TOKEN expired before push.
The token is valid for the duration of the job. If the build stage is slow and the artifact upload starts after token expiry, dotnet nuget push returns 401. Ensure the push happens in the same job or pass the token via a short-lived artifact.
Summary
- Branch name is the single source of truth for the package version:
rel/1.4.2→Version=1.4.2 - GitLab Package Registry uses
CI_JOB_TOKEN— no stored credentials needed - Artifactory publish is
when: manual+ user-restricted so only designated engineers can release externally PACKETSvariable controls exactly which packages go to Artifactory, preventing internal helpers from leaking- Build image with pre-installed
dotnet-sonarscannersaves ~90s per job by avoidingdotnet tool installat runtime