Или как обзавестись красивыми бейджиками для своего проекта за один вечер ненапряжного кодинга
Наверное, у каждого разработчика, имеющего хотя бы один пет-проект, в определённый момент возникает зуд на тему красивых бейджиков со статусами, покрытием кода, версиями пакетов в 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 — там всё достаточно прямолинейно, можно создать свой бейдж и получать его с помощью запроса.
![Пример с Shields.io](https://img.shields.io/badge/custom-badge-blue)
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 и автоматическим определением целевого окружения, что будет освещено в следующей статье.