Недавно я работал над небольшим собственным проектом, представляющим собой Roslyn-генератор интерфейсов только для чтения для существующих классов. Когда я посчитал, что пришло время выкладывать результаты в виде NuGet-пакета, я решил, что нужно создать автоматизированный конвейер сборки. В прошлом я уже решал такую задачу с помощью AppVeyor. Но на этот раз имелись некоторые отличия. Во-первых, в прошлом я использовал Cake для описания задач сборки. На этот раз я решил попробовать Nuke. Последний обещает лучшую интеграцию с Visual Studio. Кроме того, я решил попробовать русский аналог GitHub — GitVerse. Что из этого получилось, читайте ниже.
Установка Nuke
С установкой Nuke никаких проблем нет. Сперва вы устанавливается сам инструмент на ваш компьютер:
dotnet tool install Nuke.GlobalTool --global
После этого можно добавить Nuke к вашему коду. Для этого в каталоге, где расположен ваш .sln-файл, выполните следующую команду:
nuke :setup
Вас попросят ввести или подтвердить главный каталог вашего кода, имя Nuke-проекта, месторасположение этого проекта, solution, в который данный проект должен быть добавлен. После этого у вас появится новый проект, по умолчанию называемый _build
. Кроме того, в каталоге, в котором вы выполняли эту команду, вам добавят несколько файлов (build.cmd
, build.ps1
, ). Они предназначены для запуска вашего Nuke-кода. Вы можете запустить его прямо сейчас:
.\build.ps1
Ну, не совсем. Я, например, получил ошибку:
error CS0234: The type or namespace name 'FileSystemTasks' does not exist in the namespace 'Nuke.Common.IO' (are you missing an assembly reference?)
Файл сборки
Придётся запустить Visual Studio и открыть в нём свой код. В проекте _build
мы найдём файл Build.cs
. Оказывается, что наша ошибка вызвана присутствием директивы using static Nuke.Common.IO.FileSystemTasks;
, которая сейчас нам совершенно не нужна. После её удаления, запуск build.ps1
проходит без проблем.
Структура самого Build.cs
довольно привычна. Он содержит описание задач сборки, связей между ними и последовательности их выполнения. Документация Nuke очень хорошо описывает всё, что требуется знать для понимания содержимого этого файла. Рекомендую вам обратиться к ней.
А мы же займёмся написанием самих задач.
Создание каталога результатов
Как я уже сказал, документация Nuke хорошо описывает всё, что касается самого Nuke. Но нам-то нужно собирать проекты, запускать тесты, ... А вот информации о том, как это делать, в документации нет. Приходится искать её в различных местах в Интернет. Давайте начнём с того, что создадим каталог, в который будем складывать результаты работы нашего конвейера сборки.
using Nuke.Common.IO;
...
private readonly AbsolutePath OutputDirectory = RootDirectory / "output";
...
Target Clean => _ => _
.Description("Clean output directory")
.Executes(() =>
{
(RootDirectory / "src").GlobDirectories("**/bin", "**/obj").ForEach(d =>
{
d.DeleteDirectory();
});
(RootDirectory / "tests").GlobDirectories("**/bin", "**/obj").ForEach(d => d.DeleteDirectory());
(OutputDirectory).CreateOrCleanDirectory();
});
Nuke предоставляет нам свойство RootDirectory
, которое представляет собой путь к корневому каталогу. В нём мы хотим создать подкаталог output
, куда и будем складывать результаты сборки. Поскольку мы будем часто использовать этот каталог, то создадим для него поле OutputDirectory
. Обратите внимание на удобное использование оператора /
для формирования путей. Он позволят нам создавать пути к файлам и каталогам не заботясь о различиях операционных систем.
Внутри метода, выполняющего этап Clean
, мы делаем несколько вещей. Во-первых, мы удаляем все bin
и obj
каталоги. Большой необходимости в этом нет, но это позволяет мне продемонстрировать работу с glob-выражениями, а так же способ обработки результатов этих выражения.
И, наконец, с помощью метода CreateOrCleanDirectory
мы создаём наш каталог результатов. Этот метод позволяет нам не заботиться о том, существует ли уже этот каталог или нет, и есть ли в нём что-либо. После выполнения этого метода мы можем быть уверены в том, что каталог существует и он пуст.
Восстановление NuGet-зависимостей
Прежде чем приступать к сборке проекта, необходимо загрузить все NuGet-пакеты, которые он использует. Делается это так:
using Nuke.Common.Tools.DotNet;
...
[Parameter]
readonly string Solution;
[Parameter]
readonly DotNetVerbosity DotNetVerbosity = DotNetVerbosity.quiet;
...
Target Restore => _ => _
.Description("Restore dependencies")
.DependsOn(Clean)
.Executes(() =>
{
DotNetTasks.DotNetRestore(new DotNetRestoreSettings()
.SetProjectFile(RootDirectory / Solution)
.SetVerbosity(DotNetVerbosity));
});
Параметр Solution
уже был создан Nuke для нас. Теперь мы создали дополнительный параметр DotNetVerbosity
, который указывает, насколько много информации будут выдавать нам dotnet
-задачи. По умолчанию его значение - quiet
. Но вы можете изменить его значение из командной строки:
.\build.ps1 --DotNetVerbosity detailed
Обратите внимание, как мы с помощью метода DependsOn
указываем Nuke, что до исполнения этапа Restore
он обязательно должен выполнить этап Clean
. Таким образом вы можете связывать этапы выполнения сборки друг с другом. Nuke так же предоставляет вам методы Before
и After
, о которых вы можете прочесть в документации.
Сборка проекта
Теперь давайте осуществим сборку нашего проекта:
[Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
Target Compile => _ => _
.Description("Compile project")
.DependsOn(Restore)
.Executes(() =>
{
DotNetTasks.DotNetBuild(new DotNetBuildSettings()
.SetConfiguration(Configuration)
.SetNoRestore(true)
.SetProjectFile(RootDirectory / Solution)
.SetVerbosity(DotNetVerbosity));
});
Здесь мы указываем, в какой конфигурации нужно собирать проект. Кроме того, мы говорим, что восстанавливать зависимости не нужно, поскольку мы уже сделали это на предыдущем шаге.
Запуск тестов
Итак, проект собран. Теперь пришло время выполнить тесты. Я использовал для моих тестов MsTest v2, поскольку именно ими я пользуюсь на работе. Первая версия фреймворка от Microsoft была очень нехороша. Постоянно приходилось бороться с вылезающими проблемами, как только требовалось сделать что-либо не совсем тривиальное. Но разработчики провели работу над ошибками, и теперь всё работает неплохо. Не совсем отлично, конечно, проблемы по-прежнему встречаются. Например, после очередного обновление Visual Studio тесты отказывались запускаться из-под IDE до тех пор, пока не обновили версии пакетов. Но в общем работать можно.
Как же нам запустить наши тесты?
Target Test => _ => _
.Description("Run tests")
.DependsOn(Compile)
.Executes(() =>
{
DotNetTasks.DotNetTest(new DotNetTestSettings()
.SetConfiguration(Configuration)
.SetNoRestore(true)
.SetNoBuild(true)
.SetSettingsFile(RootDirectory / "tests" / "tests.runsettings")
.SetProjectFile(RootDirectory / Solution)
.SetVerbosity(DotNetVerbosity)
.SetLoggers("trx;LogFileName=mstest-results.trx")
.SetResultsDirectory(OutputDirectory));
});
Здесь мы указываем, что нам не нужно ни восстанавливать зависимости, ни производить сборку. Всё это мы уже сделали. С помощью SetLoggers
мы указываем, в каком формате и с каким именем создавать файл с результатами выполнения тестов. Он потребуется нам, чтобы показать информацию о тестах на AppVeyor:

Кроме того, для моих тестов я использую файл настроек tests.runsettings
:
<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura,opencover</Format>
<Exclude>[*.Tests?]*</Exclude>
<SkipAutoProps>true</SkipAutoProps>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
Причина, по которой он мне потребовался, заключается в следующем. Я хочу собрать информацию о покрытии моего кода тестами. В принципе, это можно сделать, добавив вызов
.SetDataCollector("XPlat Code Coverage")
Но мне требовалась более тонкая настройка. Я хотел исключить из информации о покрытии саму сборку с тестами. Именно это делает тэг Exclude
. Кроме того, покрытие автоматических свойств меня тоже не интересовало. Поэтому я добавил тэг SkipAutoProps
.
Тесты этот код исполняет. Но по какой-то причине в моём выходном каталоге он создаёт два набора файлов, содержащих описание покрытия кода тестами. Эти наборы расположены в разных каталогах. Почему так происходит, мне было недосуг разбираться. Особо мне они не мешали.
Создание отчёта о покрытии кода тестами
Теперь на основе собранной информации о покрытии кода давайте создадим отчёт в удобной для человека форме.
Target CoverageReport => _ => _
.Description("Create code coverage report")
.OnlyWhenStatic(() => IsLocalBuild)
.TriggeredBy(Test)
.Executes(() =>
{
var report = OutputDirectory.GlobFiles(@"**/*.cobertura.xml").First();
var reportDirectory = (OutputDirectory / "CodeCoverageReport");
reportDirectory.CreateOrCleanDirectory();
ReportGeneratorTasks.ReportGenerator(new ReportGeneratorSettings()
.SetReports(report)
.SetTargetDirectory(reportDirectory)
.SetReportTypes(ReportTypes.HtmlInline)
);
});
Во-первых, эти отчёты требовались мне только локально. Я не хотел создавать их на Appveyor, поскольку там нет удобного способа просматривать их. Можно было, конечно, создать публикуемый артефакт из данного отчёта, но я решил этого не делать. Поэтому для данного этапа сборки я использую условие
.OnlyWhenStatic(() => IsLocalBuild)
которое запускает данный этап только во время локальной сборки. Кроме того, создание отчёта не влияет на работоспособность остальных этапов конвейера сборки. Поэтому вместо DependsOn
мы здесь используем TriggeredBy
.
Для его работы, нам нужно найти созданный во время выполнения тестов файл с информацией о покрытии. Как я уже говорил, такие файлы создаются в нескольких экземплярах, которые лежат в разных каталогах с загадочными именами, видимо генерируемыми на основе отметок времени в момент сборки. Поэтому я просто выбираю первый попавшийся файл:
var report = OutputDirectory.GlobFiles(@"**/*.cobertura.xml").First();
Попытка запуска этого этапа выдаёт нам исключение:
System.Exception: Missing package reference/download.
Run one of the following commands:
- nuke :add-package ReportGenerator --version 5.4.4
- nuke :add-package ReportGenerator --version 5.4.3
Чтобы наш код заработал требуется добавить к проекту сборки новый NuGet-пакет. Для этого достаточно выполнить команду:
nuke :add-package ReportGenerator --version 5.4.3
Теперь наш код выполняется без проблем. В выходном каталоге в папке CodeCoverageReport
создаётся набор файлов, включая index.html
, который представляет собой отчёт о покрытии кода тестами в HTML-формате. Файл index.html
можно открыть в браузере и просмотреть этот отчёт.
Создание NuGet-пакета
Когда мы удовлетворены нашим кодом и нашими тестами, можно создавать NuGet-пакет. Для этого используется следующий этап:
Target CreateNuGet => _ => _
.Description("Create NuGet package")
.DependsOn(Test)
.Produces(OutputDirectory / "*.nupkg")
.Executes(() =>
{
DotNetTasks.DotNetPack(new DotNetPackSettings()
.SetConfiguration(Configuration)
.SetNoRestore(true)
.SetNoBuild(true)
.SetVerbosity(DotNetVerbosity)
.SetProject(RootDirectory / "src" / "Generator" / "Generator.csproj")
.SetIncludeSource(false)
.SetIncludeSymbols(false)
.SetOutputDirectory(OutputDirectory));
});
Здесь я отключил включение в NuGet-пакет исходного кода (SetIncludeSource(false)
) и создание файлов символов для отладки (SetIncludeSymbols(false)
).
Интересно так же коснуться вопроса о том, как мы задаём свойства создаваемого NuGet-пакета. Подход к этому неоднократно менялся. Когда-то использовались .nuspec
файлы. Теперь всё устанавливается через .csproj
-файл. В него я добавил PropertyGroup
:
<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<IncludeBuildOutput>false</IncludeBuildOutput>
<Version>1.0.1</Version>
<AssemblyVersion>$(Version).0</AssemblyVersion>
<AssemblyFileVersion>$(Version).0</AssemblyFileVersion>
<Authors>Ivan Yakimov</Authors>
<Description>Generator to create read-only interfaces.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageIcon>settings.png</PackageIcon>
<PackageProjectUrl>https://gitverse.ru/yakimovim/read-only-interface-generator</PackageProjectUrl>
<RepositoryUrl>https://gitverse.ru/yakimovim/read-only-interface-generator</RepositoryUrl>
<PackageTags>generator read-only interface</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
По-новому стала задаваться лицензия на использование пакета (тэг PackageLicenseExpression
). Кроме того, теперь в NuGet добавляется README
-файл (тэг PackageReadmeFile
). Это позволяет прямо на Web-странице пакета увидеть документацию на него, что очень удобно.

Публикация NuGet-пакета
Теперь, когда наш NuGet-пакет готов, пришло время опубликовать его. Для этого нам потребуется установить ещё одну зависимость:
nuke :add-package NuGet.CommandLine --version 6.12.2
Теперь пакет можно публиковать:
Target PublishNuGetToMyGet => _ => _
.Description("Publish NuGet package to MyGet")
.DependsOn(CreateNuGet)
.Executes(() =>
{
var apiKey = Environment.GetEnvironmentVariable("MyGetApiKey");
if (string.IsNullOrWhiteSpace(apiKey))
{
Log.Warning("Unable to find MyGet API Key");
return;
}
var nuGetPackage = OutputDirectory.GlobFiles("*.nupkg").SingleOrError("Unable to find NuGet package");
NuGetTasks.NuGetPush(new NuGetPushSettings()
.SetTargetPath(nuGetPackage)
.SetApiKey(apiKey)
.SetSource("https://www.myget.org/F/ivani/api/v2/package")
);
});
Здесь я публикую пакет на MyGet. API-ключ для публикации я беру из переменной окружения. AppVeyor позволяет задавать переменные окружения для сборки:

Подключение репозитория к AppVeyor
Конвейер сборки готов. Пришло время запустить его на AppVeyor. После создание проекта, необходимо указать, из какого репозитория должен браться код. Это делается на странице настроек General
:

Запуск сборки
Теперь AppVeyor знает, где найти мой исходный код. Но как запустить конвейер сборки?
Существует два пути. Во-первых, вы можете создать файл appveyor.yml
, в котором описываются все действия, которые должен предпринять AppVeyor. Этот файл должен лежать в корне вашего репозитория. Тогда AppVeyor найдёт его автоматически и выполнит находящиеся в нём инструкции. На самом деле, Nuke сам сделает этот файл для нас. Всё, что от нас требуется, применить к классу Build
соответствующий атрибут:
[AppVeyor(AppVeyorImage.VisualStudio2022)]
class Build : NukeBuild
После того, как вы запустите build.ps
1
, у вас в корневом каталоге вашего приложения появится файл appveyor.yml
примерно следующего содержания:
image:
- Visual Studio 2022
build_script:
- cmd: .\build.cmd
- sh: ./build.cmd
В результате AppVeyor прекрасно выполняет сборку. За одним исключением. У меня он не показывал список исполненных тестов. А мне очень хотелось видеть его. Чтение документации показывает, что для того, чтобы передать результаты исполнения тестов в AppVeyor, необходимо выполнить определённый код. Проблема заключается в том, что если мы используем файл appveyor.yml
для описания процесса сборки, то любые другие настройки проекта, которые мы делаем через UI, будут проигнорированы.
Поэтому, чтобы получить желаемое поведение, я отключил генерацию appveyor.yml
и убрал этот файл из репозитория. Затем я запустил сборку проекта вручную:

И, наконец, на вкладке General
я указал скрипт, который передаёт AppVeyor результаты моих тестов:

После этого результаты исполнения файлов показываются мне в UI:

Пересборка изменений
Сборка проходит прекрасно, если запускать её вручную на AppVeyor. Но мне хотелось бы, чтобы она запускалась автоматически, когда я заношу новые изменения в мой репозиторий на GitVerse. А вот этого пока не происходит. Для GitHub всё работало автоматически, с GitVerse же придётся добавить некоторые настройки.
Необходимо, чтобы GitVerse уведомлял AppVeyor о том, что содержимое репозитория изменилось. Это делается с помощью webhook. Адрес webhook AppVeyor можно найти в настройках проекта на вкладке General
:

Этот адрес нужно указать в настройках GitVerse-репозитория

Заключение
Вот и всё. Пришлось приложить определённые усилия, чтобы добиться желаемого результата. Использование Nuke в общем оставило приятное впечатление, за исключением отсутствия документации по решению конкретных задач сборки. Интеграция с AppVeyor потребовала больше усилий, чем я ожидал. Хотелось бы, чтобы тесты находились проще. Использование GitVerse потребовало только ручного подключения webhook. В остальном ничего сложного.
Надеюсь, приведённая здесь информация вам пригодится. Удачи!