CI для NuGet-библиотек: версионирование по имени ветки и публикация в два реестра

Published: 2026-05-29

Общим .NET-библиотекам нужен иной пайплайн, чем приложениям: никакого Docker-образа, никакого деплоя в Kubernetes — только dotnet pack и dotnet nuget push. Самое интересное здесь — управление версиями и публикация в два реестра: GitLab Package Registry для быстрых внутренних итераций, Artifactory для стабильных релизов, которые используют внешние команды.


Имя ветки как источник версии

У нас принято соглашение: имя ветки задаёт версию пакета:

rel/1.4.2   →  версия пакета 1.4.2

CI job извлекает её через подстановку параметров shell:

bashVERSION=${CI_COMMIT_BRANCH##*/}
# rel/1.4.2 → 1.4.2

##*/ отрезает всё до последнего / включительно. Никаких внешних инструментов, никакого git describe, никаких правок .csproj в CI.

Это значит: инженер создаёт release-ветку с правильным именем версии, ветка запускает пайплайн, и dotnet pack встраивает эту версию в метаданные .nupkg:

bashdotnet build -p:Version=$VERSION -c Release src/
dotnet pack  -p:Version=$VERSION -c Release --no-build src/

Передача -p:Version= во время сборки переопределяет <Version> в .csproj без изменения файла. --no-build в pack использует уже готовый результат сборки.


Полный 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

Файлы .nupkg передаются на стадию push через artifacts. expire_in: 1 day — после публикации в оба реестра файл больше не нужен.


Push в GitLab Package Registry

У GitLab есть встроенный NuGet feed для каждого проекта. Внешние учётные данные не нужны: CI_JOB_TOKEN — краткосрочный токен, выдаваемый на один job:

yamlpush:
  image: registry.example.com/ci-images/dotnet-build:latest
  stage: push
  script:
    # Удалить устаревший source 'push', если он остался от предыдущего запуска
    - |
      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 истекает при завершении job, поэтому --store-password-in-clear-text здесь допустим — учётные данные нигде не хранятся постоянно.

Защита grep -q "push \[" нужна, потому что nuget.config внутри образа может уже содержать source с именем push от предыдущей сборки самого образа. Без неё dotnet nuget add source завершается ошибкой «source already exists».


Push в Artifactory (стабильные релизы)

Artifactory — реестр для внешних потребителей. Туда идут только стабильные релизы, только из веток rel/*, и только при ручном запуске конкретными инженерами:

yamlpush_artifactory:
  image: registry.example.com/ci-images/dotnet-build:latest
  stage: push
  variables:
    # Список пакетов из этого репо для внешней публикации (через пробел)
    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

Ключевые решения:

Цикл push по пакетамdotnet nuget push "**/*.nupkg" опубликовал бы все пакеты репозитория. Если в нём 3 публичных пакета и 5 внутренних вспомогательных проектов, внутренние пакеты в Artifactory не нужны. Переменная PACKETS перечисляет именно то, что публикуется наружу.

Manual + ограничение по пользователюwhen: manual защищает от случайной публикации. Проверка $GITLAB_USER_LOGIN — второй барьер: даже если кто-то создаст ветку rel/, нажать кнопку публикации в Artifactory могут только конкретные люди. Ограничение срабатывает на уровне rules GitLab — ещё до обращения к Vault и каким-либо учётным данным.

Учётные данные$REGISTRY_NUGET_USERNAME и $REGISTRY_NUGET_PASSWORD — групповые CI-переменные GitLab с маскировкой и защитой (видны только на protected-ветках).


Управление NuGet source в build-образе

В build-образе есть nuget.config, указывающий на внутренний NuGet-прокси (Artifactory или групповой feed GitLab) для 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% — переменные окружения, запечённые в образ через build args. В итоге dotnet restore в CI просто работает — никакой настройки source в YAML пайплайна, образ уже знает, где живут пакеты.


Workflow rules для библиотечных пайплайнов

Библиотечные репо не деплоятся в Kubernetes, поэтому workflow проще:

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

Граничный случай: переиздание версии

Если в 1.4.2 нашли баг после публикации в Artifactory, заново запушить её нельзя (Artifactory отклоняет по умолчанию). Варианты:

  1. Опубликовать 1.4.3 с фиксом (предпочтительно).
  2. Включить «Allow re-deployment» для Artifactory-репозитория (не рекомендуется для стабильных релизов — потребители могут получить разный пакет под одной версией).
  3. Вручную удалить сломанную версию из Artifactory через UI, затем перепушить.

Используем вариант 1: ветка rel/1.4.3 создаётся из того же коммита, что и rel/1.4.2, плюс коммит с фиксом.


Итог

Задача Решение
Версионирование Имя ветки rel/X.Y.Z
Внутренняя публикация GitLab Package Registry + CI_JOB_TOKEN
Внешняя публикация Artifactory, только rel/*, вручную и только определёнными людьми
Избирательная публикация Переменная PACKETS с перечнем пакетов
Учётные данные для restore nuget.config в build-образе