Или как обзавестись красивыми бейджиками для своего проекта за один вечер ненапряжного кодинга


Наверное, у каждого разработчика, имеющего хотя бы один пет-проект, в определённый момент возникает зуд на тему красивых бейджиков со статусами, покрытием кода, версиями пакетов в 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

Приступим!


Собираем конфигурацию


Готовим аккаунты


  1. Создаём аккаунт в Microsoft Azure


  2. Переходим в Azure DevOps


  3. Создаём новый проект


    1. Имя — любое
    2. Видимость — любая
      Azure DevOps - новый проект

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


  5. Переходим в Atrifacts, жмём Create feed


    1. Вводим имя источника
    2. Выбираем видимость
    3. Снимаем галочку Include packages from common public sources, чтобы источник не превратился в помойку клон nuget
      Настройка источника пакетов

  6. Жмём Connect to feed, выбираем Visual Studio, из блока Machine Setup копируем Source
    URL источника


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


  8. Создаём новый токен доступа


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

  9. Копируем созданный токен — после закрытия модального окна значение будет недоступно


  10. Заходим в настройки репозитория в GitLab, выбираем настройки CI/CD
    GitLab CI/CD настройки


  11. Раскрываем блок Variables, добавляем новую


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


На этом предварительная настройка завершена.


Готовим каркас конфигурации


По умолчанию, для настройки 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

Естественно, есть некоторые нюансы, из-за которых мы несколько усложним команды.


  1. Мы хотим релизную, а не отладочную сборку, поэтому к каждой команде добавляем -c Release
  2. При тестировании мы хотим собирать данные о покрытии кода, поэтому потребуется подключить анализатор покрытия в тестовые библиотеки:
    1. Во все тестовые библиотеки следует добавить пакет coverlet.msbuild: dotnet add package coverlet.msbuild из папки проекта
    2. В команду запуска тестов добавим /p:CollectCoverage=true
    3. В конфигурацию задачи тестирования добавим ключ для получения результатов покрытия (см. ниже)
  3. При упаковке кода в 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+%)/

Здесь мы получаем статистику из строки с общим покрытием по линиям.


Публикуем пакеты и документацию


Оба действия у нас назначены на последний этап пайплайна — раз уж сборка и тесты прошли, можно и поделиться с миром наработками.


Для начала рассмотрим публикацию в источник пакетов:


  1. Если в проекте не присутствует файл конфигурации nuget (nuget.config), создадим новый: dotnet new nugetconfig


    Зачем: в образе может быть запрещён доступ на запись к глобальным (пользовательской и машинной) конфигурациям. Чтобы не ловить ошибки, просто создадим новую локальную конфигурацию и будем работать с ней.


  2. Добавим в локальную конфигурацию новый источник пакетов: nuget sources add -name <name> -source <url> -username <organization> -password <gitlab variable> -configfile nuget.config -StorePasswordInClearText
    1. name — локальное имя источника, не приниципиально
    2. url — URL источника из этапа "Готовим аккаунты", п. 6
    3. organization — название организации в Azure DevOps
    4. gitlab variable — имя переменной с токеном доступа, добавленной в GitLab ("Готовим аккаунты", п. 11). Естественно, в формате $variableName
    5. -StorePasswordInClearText — хак для обхода ошибки отказа в доступе (не я первый на эти грабли наступил)
    6. На случай ошибок может быть полезным добавить -verbosity detailed
  3. Отправляем пакет в источник: nuget push -source <name> -skipduplicate -apikey <key> *.nupkg
    1. Отправляем все пакеты из текущей директории, поэтому *.nupkg.
    2. name — из шага выше.
    3. key — любая строка. В Azure DevOps в окне Connect to feed всегда в качестве примера приводят строку az.
    4. -skipduplicate — при попытке отправить уже существующий пакет без этого ключа источник вернёт ошибку 409 Conflict; с ключом отправка будет пропущена.

Теперь настроим создание документации:


  1. Для начала, в репозитории, в ветке master, инициализируем проект docfx. Для этого из корня надо выполнить команду docfx init и в интерактивном режиме зададим ключевые параметры для сборки документации. Подробное описание минимальной настройки проекта здесь.
    1. При настройке важно указать выходную директорию ..\public — GitLab по умолчанию берёт содержимое папки public в корне репозитория как источник для Pages. Т.к. проект будет располагаться во вложенной в репозиторий папке — добавляем в путь выход на уровень вверх.
  2. Отправим изменения в GitLab.
  3. В конфигурацию пайплайна добавим задачу pages (зарезервированное слово для задач публикации сайтов в GitLab Pages):
    1. Скрипт:
      1. nuget install docfx.console -version 2.51.0 — установит docfx; версия указана для гарантии правильности путей установки пакета.
      2. .\docfx.console.2.51.0\tools\docfx.exe .\docfx_project\docfx.json — собираем документацию
    2. Узел artifacts:

pages:
  # snip
  artifacts:
    paths:
      - public

Лирическое отступление про docfx


Раньше при настройке проекта я указывал источник кода для документации как файл решения. Основной минус — документация создаётся и для тестовых проектов. В случае, если это не нужно, можно задать такое значение узлу metadata.src:


{
  "metadata": [
    {
      "src": [
        {
          "src": "../",
          "files": [
            "**/*.csproj"
          ],
          "exclude":[
            "*.tests*/**"
          ]
        }
      ],
      // --- snip ---
    },
    // --- snip ---
  ],
  // --- snip ---
}

  1. metadata.src.src: "../" — выходим на уровень вверх относительно расположения docfx.json, т.к. в паттернах не работает поиск вверх по дереву директорий.
  2. metadata.src.files: ["**/*.csproj"] — глобальный паттерн, собираем все проекты C# из всех директорий.
  3. metadata.src.exclude: ["*.tests*/**"] — глобальный паттерн, исключаем всё из папок с .tests в названии

Промежуточный итог


Вот такую простую конфигурацию можно составить буквально за полчаса и пару чашек кофе, которая позволит при каждом запросе слияния и отправке в мастер проверять, что код собирается и тесты проходят, собирать новый пакет, обновлять документацию и радовать глаз красивыми бейджиками в README проекта.


Итоговый .gitlab-ci.yml
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:


Бейджи в GitLab


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


![Пример с Shields.io](https://img.shields.io/badge/custom-badge-blue)

Пример с Shields.io


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


Create badge на Azure DevOps


Azure DevOps - информация о бейдже


Добавляем красоты


Выделяем общие фрагменты конфигурации


Во время написания конфигурации и поисков по документации, я наткнулся на интересную возможность 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 и автоматическим определением целевого окружения, что будет освещено в следующей статье.