NuGet - отличный пакетный менеджер, который решает огромное количество задач. Иногда даже те задачи, под которые он изначально не разрабатывался. Но тут перед разработчиком появляется задача - “докатить” свой код до пакетного менеджера. Сделать “хоть как-то” достаточно легко и из-за этого появляется множество способов достижения цели. Конфигурация сборки и упаковки проходит жизненный путь вместе с проектом и разработчиками, улучшается, эволюционирует. И чем больше разных способов решения изначально задачи, тем сложнее актуализировать и удерживать в консистентном состоянии процесс, когда количество пакетов переваливает за 30.
Этап 0. Ручная загрузка
Загрузка пакета начинается с его сборки. В .NET для этого есть CLI, который позволяет легко собрать из проекта пакет:
dotnet build -c Release MyProject.csproj
dotnet pack MyProject.csproj
На выходе получится .nupkg файл, который и распространяется пакетным менеджером. Важным элементом архитектуры пакетных менеджеров являются централизованные репозитории, где эти пакеты хранятся. В случае NuGet основным таким является nuget.org. Репозитории предоставляют набор HTTP Endpoint’ов, через которые можно загрузить свой пакет. В случае nuget.org на самом сайте даже есть Web UI куда можно dran’n’drop’ом закинуть файл. После того как файл будет загружен, nuget проанализирует его, проверит, проиндексирует и через несколько минут он станет доступным для скачивания.
Этап 1. Интеграция в CI/CD
Проблема этапа 0 отражена в названии. Это ручная работа, которая может быть ок при 1-3-5 пакетах. Но перекладывать руками пакеты после сборки каждой новой версии, когда их 10-30-50 уже становится больно. На помощь приходит CI/CD. Обычно репозитории существуют в какой-то системе управления проектами, которая поддерживает CI/CD и даёт возможность настроить процесс сборки и развёртывания пакетов. Например, у GitHub репозитории можно завести файл .github/workflows/ci-cd.yaml
, который будет парсится GitHub’ом при работе с репозиторием и выполнятся.
Пример такой конфигурации:
К нему достаточно добавить шаг с выполнением dotnet pack
и загрузить в nuget.org:
Этап 2. Версионирование пакета
В тексте про Solution configuration было описано как можно распространять настройки для пакетов с помощью пакета. Но версия - это значение, которое уникальное для каждого проекта или группы пакетов в solution’е. Это значит, что задавать нужно руками в Directory.Build.props. А также обновлять руками перед публикацией. Первая проблема данного подхода заметка даже в CI шаге:
Рассмотрим сценарий:
- Создаётся коммит с изменениями, вешается версия 1.2.3;
- Запускается CI, собирается пакет, публикуется;
- Создаётся коммит с новыми изменениями, но не увеличивается версия;
- Запускается CI, собирается пакет, начинается публикация;
- Публикация падает с тем, что пакет с версией 1.2.3 уже существует.
Данная проблема решается флагом --skip-duplicate
, который говорит nuget.org’у, что это окей, если пакет с такой версией существует. В такой ситуации ничего не публикуется.
Вторая проблема - это необходимость фиксировать в коде версии до того, как изменения попадут в репозиторий. Во-первых, разработчики постоянно забывают увеличивать версию и приходится отдельным PR докидывать увеличение версии. Во-вторых, может быть такой процесс разработки, когда версию увеличивают не после каждого изменения, а только когда было принято решение, что пора. В такой ситуации также необходимо будет создавать отдельный PR и явно увеличивать версию.
Третья проблема - помимо версий в .props
файле было бы круто ещё и повесить git-теги, чтобы по истории комитов легко можно было бы найти нужную версию. Это значит, что нужно каждую версию указывать дважды.
У этих проблем есть множество разных решений, но одно из них - это версионирование построенное на метаданных git’а. Есть несколько инструментов, которые поддерживают такое поведение: adamralph/minver, dotnet/Nerdbank.GitVersioning, GitTools/GitVersion. Они работают немного разным способом. Был выбран minver как самый простой из них. Алгоритм работы такой:
- Удаляется указание версии пакета из исходного кода - пакет теперь собирается с версий 1.0.0
- В проект, который пакуется, добавляет использование MinVer.
- Запускается сборка. Во время сборки MinVer вытаскивает информацию о git репозитории, где он находится, ищет последний тег и вставляет его как версию.
- Если тег висит на текущем комите, то он используется as is
- Если относительно последнего коммита с тегом появились новые теги без коммитов, то добавляется постфикс
-alpha.{количество коммитов}
- Если тегов никаких не было, то используется
0.0.0-aplha.{количество комитов}
Это означает, что теперь теги git’а контролируют версию, а значит проблема №3 закрыта. Более того, выставление тегов отвязано от создания коммитов, а значит их можно навесить уже после создания. И проблема №2 также будет решена.
Этап 3. Символы
Если отладка кода кажется чем-то сложным, то у вас простой проект. На больших проектах отладка - это всегда сущий ад. И пакеты внесли в это свой вклад. По мере распространения пакетов всё чаще появлялись ситуации, когда нужно было отладить не свой код, а код пакета. Со своим кодом всё просто - он собран в debug’е, есть символы от него. А вот пакеты собираются в Release и с ними уже символов нет.
Но на самом деле решение уже есть, но не все пакеты его поддерживают. Это решение - это .snupkg
. Идея данного подхода в том, что символы можно распространять точно также, как и пакеты используя пакетный менеджер. Более подробно описано в документации Microsoft: https://learn.microsoft.com/en-us/nuget/create-packages/symbol-packages-snupkg.
Для разработчика это превращается в два действия. Первое - это добавление свойств в MSBuild для генерации .snupkg
:
Второе - это добавление публикации сгенерированного файла вместе с пакетом:
Этап 4. Синхронизация action’ов между репозиториями
И вот наступает момент, когда нужно внести изменения в ci-cd.yaml
. А репозиториев уже 30. А после того, как руками 30 репозиториев будут изменены вдруг окажется, что помимо публикации пакетов хотелось бы ещё для всех нюгетов генерировать test coverage. И нужно ещё раз пройтись по 30 репозиториям.
Ситуация напоминает проблему с конфигурацией solution’ов и решается таким же способом - избавлением от дублирования. И нет, никто не будет добавлять ci-cd.yaml
файл в пакет. Решение будет рассмотрено на примере GitHub, но в Azure DevOps эта задача решается аналогично (даже проще, местами). Решение строится на Resusable workflows - https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview. GitHub Actions позволяет вызывать из workflow другой workflow. Делается это в два шага. Шаг первый - выделение “образцового” workflow. Создаётся в отдельном репозитории workflow, который описывает универсальный CI/CD для всех репозиториев с нюгетами. С большой вероятностью, если это схожие пакеты, которые пишет одна команда, то он будет идентичный CI/CD и его можно шаблонизировать:
Второй шаг - замена во всех репозиториях логики из ci-cd.yaml
на вызов данного workflow:
Таким образом решается проблема дублирования и появляется централизованный workflow, который работает для всех репозиториев. Разумеется, в какой-то момент универсальность даст сбой, появится шаг, который нужен только для одного репозитория. Примером такого шага может стать дополнительная зависимость на компонент, который нужно установить:
Но тут уже нужно балансировать между сложностью использовать общий шаблон и затратами на поддержку множества конфигураций.
Этап 5. dotnet-releaser
После этапа 4 уже достигнута точка, на котором можно было бы остановиться. Но иногда хочется, чтобы для проблемы существовал инструмент, который с коробки решает проблемы пользователя даже лучше, чем сам пользователь мог бы представить. И самое главное, что такие инструменты существуют. Один из них - это xoofx/dotnet-releaser. dotnet-releaser
- это CLI приложение, которые выполняет набор шагов необходимых для публикации NuGet пакета:
- Сборка solution’а
- Выполнение тестов (и генерация test coverage)
- Создание пакета
- Публикация пакета
- (!) Создание GitHub release
Это значит, что вместо всего нашего ci-cd.yaml
можно написать:
И весь функционал библиотеки dotnet-releaser
появится в CI/CD репозитория. О всех возможностях можно почитать в user guide. Но из интересного (помимо замены всех шагов, которые и так был раньше указаны) есть генерация GitHub release. dotnet-releaser
отслеживает изменения версии и при добавлении тега запускает формирование дельты относительно прошлой версии. dotnet-releaser парсит коммиты и Pull requrest’ы, которые были сделаны между двумя тегами и по ним генерирует GitHub release: https://github.com/xoofx/markdig/releases/tag/0.37.0.
Summary
Был рассмотрен поэтапный процесс эволюции подхода к публикации NuGet пакета. На входе был ручной процесс, который требовал много усилий, усложнялся с каждым новым репозиторием. На выходе получили переиспользуемый workflow, который легко включается в новый репозиторий и задаёт минимальный уровень качества процесса сборки.