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 loopdotnet 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 restrictionwhen: 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:

  1. Push 1.4.3 with the fix (preferred).
  2. Enable "Allow re-deployment" for the Artifactory repo (not recommended for stable releases — consumers might get a different package for the same version).
  3. 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.2Version=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
  • PACKETS variable controls exactly which packages go to Artifactory, preventing internal helpers from leaking
  • Build image with pre-installed dotnet-sonarscanner saves ~90s per job by avoiding dotnet tool install at runtime