Или как обзавестись красивыми бейджиками для своего проекта за один вечер ненапряжного кодинга
Наверное, у каждого разработчика, имеющего хотя бы один пет-проект, в определённый момент возникает зуд на тему красивых бейджиков со статусами, покрытием кода, версиями пакетов в nuget… И меня этот зуд привёл к написанию этой статьи. В процессе подготовки к её написанию я обзавёлся вот такой красотой в одном из своих проектов:

В статье будет рассмотрена базовая настройка непрерывной интеграции и поставки для проекта библиотеки классов на .Net Core в GitLab, с публикацией документации в GitLab Pages и отправкой собранных пакетов в приватный фид в Azure DevOps.
В качестве среды разработки использовалась VS Code c расширением GitLab Workflow (для валидации файла настроек прямо из среды разработки).
Краткое введение
CD — это когда ты только пушнул, а у клиента уже всё упало?
Что такое CI/CD и зачем нужно — можно легко нагуглить. Полноценную документацию по настройке пайплайнов в GitLab найти также несложно. Здесь я кратко и по возможности без огрехов опишу процесс работы системы с высоты птичьего полёта:
- разработчик отпраляет коммит в репозиторий, создаёт merge request через сайт, или ещё каким-либо образом явно или неявно запускает пайплайн,
- из конфигурации выбираются все задачи, условия которых позволяют их запустить в данном контексте,
- задачи организуются в соответствии со своими этапами,
- этапы по очереди выполняются — т.е. параллельно выполняются все задачи этого этапа,
- если этап завершается неудачей (т.е. завершается неудачей хотя бы одна из задач этапа) — пайплайн останавливается (почти всегда),
- если все этапы завершены успешно, пайплайн считается успешно прошедшим.
Таким образом, имеем:
- пайплайн — набор задач, организованных в этапы, в котором можно собрать, протестировать, упаковать код, развернуть готовую сборку в облачный сервис, и пр.,
- этап (stage) — единица организации пайплайна, содержит 1+ задачу,
- задача (job) — единица работы в пайплайне. Состоит из скрипта (обязательно), условий запуска, настроек публикации/кеширования артефактов и много другого.
Соответственно, задача при настройке CI/CD сводится к тому, чтобы создать набор задач, реализующих все необходимые действия для сборки, тестирования и публикации кода и артефактов.
- Почему GitLab?
Потому, что когда появилась необходимость создать приватные репозитории под пет-проекты, на GitHub'e они были платными, а я — жадным. Репозитории стали бесплатными, но пока это не является для меня поводом достаточным переезжать на GitHub.
- Почему не Azure DevOps Pipelines?
Потому что там настройка элементарная — даже не требуются знания командной строки. Интеграция с внешними провайдерами git — в пару кликов, импорт SSH-ключей для отправки коммитов в репозиторий — тоже, пайплайн легко настраивается даже не из шаблона.
Исходная позиция: что имеется и чего хочется
Имеем:
- репозиторий в GitLab.
Хотим:
- автоматическую сборку и тестирование для каждого merge request,
- сборку пакетов для каждого merge request и пуша в мастер при условии наличия в сообщении коммита определённой строки,
- отправку собранных пакетов в приватный фид в Azure DevOps,
- сборку документации и публикацию в GitLab Pages,
- бейджики!11
Описанные требования органично ложатся на следующую модель пайплайна:
- Этап 1 — сборка
- Собираем код, выходные файлы публикуем как артефакты
- Этап 2 — тестирование
- Получаем артефакты с этапа сборки, гоняем тесты, собраем данные покрытия кода
- Этап 3 — отправка
- Задача 1 — собираем nuget-пакет и отправляем в Azure DevOps
- Задача 2 — собираем сайт из xmldoc в исходном коде и публикуем в GitLab Pages
Приступим!
Собираем конфигурацию
Готовим аккаунты
Создаём аккаунт в Microsoft Azure
Переходим в Azure DevOps
Создаём новый проект
- Имя — любое
- Видимость — любая

При нажатии на кнопку Create проект будет создан, и будет совершён переход на его страницу. На этой странице можно отключить ненужные возможности, перейдя в настройки проекты (нижняя ссылка в списке слева -> Overview -> блок Azure DevOps Services)

Переходим в Atrifacts, жмём Create feed
- Вводим имя источника
- Выбираем видимость
- Снимаем галочку Include packages from common public sources, чтобы источник не превратился в
помойкуклон nuget

Жмём Connect to feed, выбираем Visual Studio, из блока Machine Setup копируем Source

Идём в настройки аккаунта, выбираем Personal Access Token

Создаём новый токен доступа
- Имя — произвольное
- Организация — текущая
- Срок действия — максимум 1 год
- Область действия (scope) — Packaging/Read & Write

Копируем созданный токен — после закрытия модального окна значение будет недоступно
Заходим в настройки репозитория в GitLab, выбираем настройки CI/CD

Раскрываем блок Variables, добавляем новую
- Имя — любое без пробелов (будет доступно в командной оболочке)
- Значение — токен доступа из п. 9
- Выбираем Mask variable

На этом предварительная настройка завершена.
Готовим каркас конфигурации
По умолчанию, для настройки CI/CD в GitLab используется файл .gitlab-ci.yml из корня репозитория. Можно настроить произвольный путь до этого файла в настройках репозитория, но в данном случае это не нужно.
Как видно из расширения, файл содержит конфигурацию в формате YAML. В документации подробно описано, какие ключи могут содержаться на верхнем уровне конфигурации, и в каждом из вложенных уровней.
Сначала добавим в файл конфигурации ссылку на docker-образ, в котором будет происходить выполнение задач. Для этого находим страницу образов .Net Core в Docker Hub. В GitHub есть подробное руководство, какой выбрать образ для разных задач. Нам для сборки подойдёт образ с .Net Core 3.1, поэтому смело добавляем первой строкой в конфигурацию
image: mcr.microsoft.com/dotnet/core/sdk:3.1
Теперь при запуске пайплайна с хранилища образов Microsoft будет скачан указанный образ, в котором и будут исполняться все задачи из конфигурации.
Следующий этап — добавить stage'ы. По умолчанию GitLab определяет 5 этапов:
.pre— выполняется до всех этапов,.post— выполняется после всех этапов,build— первый после.preэтап,test— второй этап,deploy— третий этап.
Ничего не мешает объявить их явно, впрочем. Порядок, в котором указаны этапы, влияет на порядок, в котором они выполняются. Для полноты изложения, добавим в конфигурацию:
stages: - build - test - deploy
Для отладки имеет смысл получить информацию об окружении, в котором исполняются задачи. Добавим глобальный набор команд, который будет выполняться перед каждой задачей, с помощью before_script:
before_script: - $PSVersionTable.PSVersion - dotnet --version - nuget help | select-string Version
Осталось добавить хотя бы одну задачу, чтобы при отправке коммитов пайплайн запустился. Пока что добавим пустую задачу для демонстрации:
dummy job: script: - echo ok
Запускаем валидацию, получаем сообщение, что всё хорошо, коммитим, пушим, смотрим на сайте на результаты… И получаем ошибку скрипта — bash: .PSVersion: command not found. WTF?
Всё логично — по умолчанию runner'ы (отвечающие за исполнение скриптов задач, и предоставляемые GitLab'ом) используют bash для исполнения команд. Можно исправить это дело, явно указав в описании задачи, какие теги должны быть у исполняющего пайплайн раннера:
dummy job on windows: script: - echo ok tags: - windows
Отлично! Теперь пайплайн выполняется.
Внимательный читатель, повторив указанные шаги, заметит, что задача выполнилась в этапе test, хотя мы не указывали этап. Как можно догадаться, test является этапом по умолчанию.
Продолжим создание скелета конфигурации, добавив все задачи, описанные выше:
build job: script: - echo "building..." tags: - windows stage: build test and cover job: script: - echo "running tests and coverage analysis..." tags: - windows stage: test pack and deploy job: script: - echo "packing and pushing to nuget..." tags: - windows stage: deploy pages: script: - echo "creating docs..." tags: - windows stage: deploy
Получили не особенно функциональный, но тем не менее корректный пайплайн.
Настройка триггеров
Из-за того, что ни для одной из задач не указаны фильтры срабатывания, пайплайн будет полностью исполняться при каждой отправке коммитов в репозиторий. Так как это не является желаемым поведением в общем случае, мы настроим фильтры срабатывания для задач.
Фильтры могут настраиваться в двух форматах: only/except и rules. Вкратце, only/except позволяет настраивать фильтры по триггерам (merge_request, например — настраивает задачу на выполнение при каждом создании запроса на слияние и при каждой отправке коммитов в ветку, являющуюся исходной в запросе на слияние) и именам веток (в т.ч. с использованием регулярных выражений); rules позволяет настраивать набор условий и, опционально, изменять условие выполнения задачи в зависимости от успеха предшествующих задач (when в GitLab CI/CD).
Вспомним набор требований — сборка и тестирование только для merge request, упаковка и отправка в Azure DevOps — для merge request и пушей в мастер, генерация документации — для пушей в мастер.
Для начала настроим задачу сборки кода, добавив правило срабатывания только при merge request:
build job: # snip only: - merge_request
Теперь настроим задачу упаковки на срабатывания на merge request и добавление коммитов в мастер:
pack and deploy job: # snip only: - merge_request - master
Как видно, всё просто и прямолинейно.
Также можно настроить задачу на срабатывание только если создан merge request с определённой целевой или исходной веткой:
rules: - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"
В условиях можно использовать перечисленные здесь переменные; правила rules не совместимы с правилами only/except.
Настройка сохранения артефактов
Во время выполнения задачи build job у нас будут созданы артефакты сборки, которые можно переиспользовать в последующих задачах. Для этого нужно в конфигурацию задачи добавить пути, файлы по которым нужно будет сохранить и переиспользовать в следующих задачах, в ключ artifacts:
build job: # snip artifacts: paths: - path/to/build/artifacts - another/path - MyCoolLib.*/bin/Release/*
Пути поддерживают wildcards, что определённо упрощает их задание.
Если задача создаёт артефакты, то каждая последующая задача сможет их получить к ним доступ — они будут располагаться по тем же путям относительно корня репозитория, по которым были собраны из исходной задачи. Так же артефакты доступны для скачивания на сайте.
Теперь, когда у нас готов (и проверен) каркас конфигурации, можно переходить собственно к написанию скриптов для задач.
Пишем скрипты
Возможно, когда-то давно, в далёкой-далёкой галактике, собирать проекты (в том числе и на .net) из командной строки было болью. Сейчас же собрать, протестировать и опубликовать проект можно в 3 команды:
dotnet build dotnet test dotnet pack
Естественно, есть некоторые нюансы, из-за которых мы несколько усложним команды.
- Мы хотим релизную, а не отладочн��ю сборку, поэтому к каждой команде добавляем
-c Release - При тестировании мы хотим собирать данные о покрытии кода, поэтому потребуется подключить анализатор покрытия в тестовые библиотеки:
- Во все тестовые библиотеки следует добавить пакет
coverlet.msbuild:dotnet add package coverlet.msbuildиз папки проекта - В команду запуска тестов добавим
/p:CollectCoverage=true - В конфигурацию задачи тестирования добавим ключ для получения результатов покрытия (см. ниже)
- Во все тестовые библиотеки следует добавить пакет
- При упаковке кода в nuget-пакеты зададим выходную директорию для пакетов:
-o .
Собираем данные покрытия кода
Coverlet после запуска тестов выводит в консоль статистику по запуску:
Calculating coverage result... Generating report 'C:\Users\xxx\source\repos\my-project\myProject.tests\coverage.json' +-------------+--------+--------+--------+ | Module | Line | Branch | Method | +-------------+--------+--------+--------+ | project 1 | 83,24% | 66,66% | 92,1% | +-------------+--------+--------+--------+ | project 2 | 87,5% | 50% | 100% | +-------------+--------+--------+--------+ | project 3 | 100% | 83,33% | 100% | +-------------+--------+--------+--------+ +---------+--------+--------+--------+ | | Line | Branch | Method | +---------+--------+--------+--------+ | Total | 84,27% | 65,76% | 92,94% | +---------+--------+--------+--------+ | Average | 90,24% | 66,66% | 97,36% | +---------+--------+--------+--------+
GitLab позволяет указать регулярное выражение для получения статистики, которую потом можно получить в виде бейджа. Регулярное выражение указывается в настройках задачи с ключом coverage; в выражении должна присутствовать capture-группа, значение которой и будет передано в бейдж:
test and cover job: # snip coverage: /\|\s*Total\s*\|\s*(\d+[,.]\d+%)/
Здесь мы получаем статистику из строки с общим покрытием по линиям.
Публикуем пакеты и документацию
Оба действия у нас назначены на последний этап пайплайна — раз уж сборка и тесты прошли, можно и поделиться с миром наработками.
Для начала рассмотрим публикацию в источник пакетов:
Если в проекте не присутствует файл конфигурации nuget (
nuget.config), создадим новый:dotnet new nugetconfig
Зачем: в образе может быть запрещён доступ на запись к глобальным (пользовательской и машинной) конфигурациям. Чтобы не ловить ошибки, просто создадим новую локальную конфигурацию и будем работать с ней.
- Добавим в локальную конфигурацию новый источник пакетов:
nuget sources add -name <name> -source <url> -username <organization> -password <gitlab variable> -configfile nuget.config -StorePasswordInClearText
name— локальное имя источника, не приниципиальноurl— URL источника из этапа "Готовим аккаунты", п. 6organization— название организации в Azure DevOpsgitlab variable— имя переменной с токеном доступа, добавленной в GitLab ("Готовим аккаунты", п. 11). Естественно, в формате$variableName-StorePasswordInClearText— хак для обхода ошибки отказа в доступе (не я первый на эти грабли наступил)- На случай ошибок может быть полезным добавить
-verbosity detailed
- Отправляем пакет в источник:
nuget push -source <name> -skipduplicate -apikey <key> *.nupkg
- Отправляем все пакеты из текущей директории, поэтому
*.nupkg. name— из шага выше.key— любая строка. В Azure DevOps в окне Connect to feed всегда в качестве примера приводят строкуaz.-skipduplicate— при попытке отправить уже существующий пакет без этого ключа источник вернёт ошибку409 Conflict; с ключом отправка будет пропущена.
- Отправляем все пакеты из текущей директории, поэтому
Теперь настроим создание документации:
- Для начала, в репозитории, в ветке master, инициализируем проект docfx. Для этого из корня надо выполнить команду
docfx initи в интерактивном режиме зададим ключевые параметры для сборки документации. Подробное описание минимальной настройки проекта здесь.
- При настройке важно указать выходную директорию
..\public— GitLab по умолчанию берёт содержимое папки public в корне репозитория как источник для Pages. Т.к. проект будет располагаться во вложенной в репозиторий папке — добавляем в путь выход на уровень вверх.
- При настройке важно указать выходную директорию
- Отправим изменения в GitLab.
- В конфигурацию пайплайна добавим задачу
pages(зарезервированное слово для задач публикации сайтов в GitLab Pages):
- Скрипт:
nuget install docfx.console -version 2.51.0— установит docfx; версия указана для гарантии правильности путей установки пакета..\docfx.console.2.51.0\tools\docfx.exe .\docfx_project\docfx.json— собираем документацию
- Узел artifacts:
- Скрипт:
pages: # snip artifacts: paths: - public
Лирическое отступление про docfx
Раньше при настройке проекта я указывал источник кода для документации как файл решения. Основной минус — документация создаётся и для тестовых проектов. В случае, если это не нужно, можно задать такое значение узлу metadata.src:
{ "metadata": [ { "src": [ { "src": "../", "files": [ "**/*.csproj" ], "exclude":[ "*.tests*/**" ] } ], // --- snip --- }, // --- snip --- ], // --- snip --- }
metadata.src.src: "../"— выходим на уровень вверх относительно расположенияdocfx.json, т.к. в паттернах не работает поиск вверх по дереву директорий.metadata.src.files: ["**/*.csproj"]— глобальный паттерн, собираем все проекты C# из всех директорий.metadata.src.exclude: ["*.tests*/**"]— глобальный паттерн, исключаем всё из папок с.testsв названии
Промежуточный итог
Вот такую простую конфигурацию можно составить буквально за полчаса и пару чашек кофе, которая позволит при каждом запросе слияния и отправке в мастер проверять, что код собирается и тесты проходят, собирать новый пакет, обновлять документацию и радовать глаз красивыми бейджиками в README проекта.
image: mcr.microsoft.com/dotnet/core/sdk:3.1 before_script: - $PSVersionTable.PSVersion - dotnet --version - nuget help | select-string Version stages: - build - test - deploy build job: stage: build script: - dotnet build -c Release tags: - windows only: - merge_requests - master artifacts: paths: - your/path/to/binaries test and cover job: stage: test tags: - windows script: - dotnet test -c Release /p:CollectCoverage=true coverage: /\|\s*Total\s*\|\s*(\d+[,.]\d+%)/ only: - merge_requests - master pack and deploy job: stage: deploy tags: - windows script: - dotnet pack -c Release -o . - dotnet new nugetconfig - nuget sources add -name feedName -source https://pkgs.dev.azure.com/your-organization/_packaging/your-feed/nuget/v3/index.json -username your-organization -password $nugetFeedToken -configfile nuget.config -StorePasswordInClearText - nuget push -source feedName -skipduplicate -apikey az *.nupkg only: - master pages: tags: - windows stage: deploy script: - nuget install docfx.console -version 2.51.0 - $env:path = "$env:path;$($(get-location).Path)" - .\docfx.console.2.51.0\tools\docfx.exe .\docfx\docfx.json artifacts: paths: - public only: - master
Кстати о бейджиках
Ради них ведь всё и затевалось!
Бейджи со статусами пайплайна и покрытием кода доступны в GitLab в настройках CI/CD в блоке Gtntral pipelines:

Бейдж со ссылкой на документацию я создавал на платформе Shields.io — там всё достаточно прямолинейно, можно создать свой бейдж и получать его с помощью запроса.

Azure DevOps Artifacts также позволяет создавать бейджи для пакетов с указанием актуальной версии. Для этого в источнике на сайте Azure DevOps нужно нажать на Create badge у выбранного пакета и скопировать markdown-разметку:


Добавляем красоты
Выделяем общие фрагменты конфигурации
Во время написания конфигурации и поисков по документации, я наткнулся на интересную возможность YAML — переиспользование фрагментов.
Как видно из настроек задач, все они требуют наличия тега windows у раннера, и срабатывают при отправке в мастер/создании запроса на слияние (кроме документации). Добавим это во фрагмент, который будем переиспользовать:
.common_tags: &common_tags tags: - windows .common_only: &common_only only: - merge_requests - master
И теперь в описании задачи можем вставить объявленный ранее фрагмент:
build job: <<: *common_tags <<: *common_only
Названия фрагментов должны начинаться с точки, чтобы не быть интерпретированными как задача.
Версионирование пакетов
При создании пакета компилятор проверяет ключи командной строки, и в их отсутствие — файлы проектов; найдя узел Version, он берёт его значение как версию собираемого пакета. Выходит, чтобы собрать пакет с новой версией, нужно либо обновить её в файле проекта, либо передать как аргумент командной строки.
Добавим ещё одну хотелку — пусть младшие два номера в версии будут годом и датой сборки пакета, и добавим пререлизные версии. Добавлять эти данные в файл проекта и проверять перед каждой отправкой можно, конечно — но можно ведь это делать и в пайплайне, собирая версию пакета из контекста и передавая через аргумент командной строки.
Условимся, что если в сообщении коммита есть строка вида release (v./ver./version) <version number> (rev./revision <revision>)?, то мы будем из этой строки брать версию пакета, дополнять её текущей датой и передавать как аргумент команде dotnet pack. В отсутствие строки — просто не будем собирать пакет.
Данную задачу решает следующий скрипт:
# регулярное выражение для поиска строки с версией $rx = "release\s+(v\.?|ver\.?|version)\s*(?<maj>\d+)(?<min>\.\d+)?(?<rel>\.\d+)?\s*((rev\.?|revision)?\s+(?<rev>[a-zA-Z0-9-_]+))?" # ищем строку в сообщении коммита, передаваемом в одной из предопределяемых GitLab'ом переменных $found = $env:CI_COMMIT_MESSAGE -match $rx # совпадений нет - выходим if (!$found) { Write-Output "no release info found, aborting"; exit } # извлекаем мажорную и минорную версии $maj = $matches['maj'] $min = $matches['min'] # если строка содержит номер релиза - используем его, иначе - текущий год if ($matches.ContainsKey('rel')) { $rel = $matches['rel'] } else { $rel = ".$(get-date -format "yyyy")" } # в качестве номера сборки - текущие месяц и день $bld = $(get-date -format "MMdd") # если есть данные по пререлизной версии - включаем их в версию if ($matches.ContainsKey('rev')) { $rev = "-$($matches['rev'])" } else { $rev = '' } # собираем единую строку версии $version = "$maj$min$rel.$bld$rev" # собираем пакеты dotnet pack -c Release -o . /p:Version=$version
Добавляем скрипт в задачу pack and deploy job и наблюдаем сборку пакетов строго при наличии заданной строки в сообщении коммита.
Итого
Потратив примерно полчаса-час времени на написание конфигурации, отладку в локальном powershell и, возможно, пару неудачных запусков, мы получили несложную конфигурацию для автоматизации рутинных задач.
Конечно, GitLab CI/CD гораздо обширнее и многограннее, чем может показаться после прочтения этого руководства — это совершенно не так. Там даже Auto DevOps есть, позволяющий
automatically detect, build, test, deploy, and monitor your applications
Теперь в планах — сконфигурировать пайплайн для развёртывания приложений в Azure, с использованием Pulumi и автоматическим определением целевого окружения, что будет освещено в следующей статье.
