目录
Intro
最近我们在迁移 CI,从原来的 Jenkin 迁移到了 Bitbucket Pipeline,在这一过程中,对于 bash 脚本不太熟悉的我也可以先借助我最熟悉的 C# 来达到目标,我也将我的开源项目中的借助 cake build 来写的构建脚本换成了原生的 C# 代码,并通过 dotnet-exec 来在 CI 的时候执行我的脚本,在之前每次需要修改 cake 脚本的时候都要去查 cake 支持的 API,使用原生的 C# 代码之后,就可以使用自己最熟悉的语言,使用自己最喜欢的 nuget 包来实现自己的目标
Sample
之前使用 cake build 的主要场景执行 dotnet build, dotnet test 以及 dotnet pack 和 dotnet nuget push,之前的 cake 脚本会使用 cake 提供的一些 API
Task("build")
.Description("Build")
.IsDependentOn("clean")
.IsDependentOn("restore")
.Does(() =>
{
var buildSetting = new DotNetBuildSettings
{
NoRestore = true,
Configuration = configuration
};
foreach (var project in srcProjects)
{
DotNetBuild(project.FullPath, buildSetting);
}
});
Task("test")
.Description("Test")
.IsDependentOn("build")
.Does(() =>
{
var testSettings = new DotNetTestSettings
{
NoRestore = false
};
foreach (var project in testProjects)
{
DotNetTest(project.FullPath, testSettings);
}
});
为了迁移的方便,同时也是借鉴 cake 的 API,改造出来下面的使用 API:
await BuildProcess.CreateBuilder()
.WithSetup(() =>
{
// cleanup artifacts
if (Directory.Exists("./artifacts/packages"))
Directory.Delete("./artifacts/packages", true);
})
.WithTask("hello", b => b.WithExecution(() => Console.WriteLine("Hello dotnet-exec build")))
.WithTask("build", b =>
{
b.WithDescription("dotnet build")
.WithExecution(cancellationToken => ExecuteCommandAsync($"dotnet build {solutionPath}", cancellationToken))
;
})
.WithTask("test", b =>
{
b.WithDescription("dotnet test")
.WithDependency("build")
.WithExecution(async cancellationToken =>
{
foreach (var project in testProjects)
{
await ExecuteCommandAsync($"dotnet test --collect:\"XPlat Code Coverage;Format=cobertura,opencover;ExcludeByAttribute=ExcludeFromCodeCoverage,Obsolete,GeneratedCode,CompilerGeneratedAttribute\" {project}", cancellationToken);
}
})
;
})
.WithTask("pack", b => b.WithDescription("dotnet pack")
.WithDependency("test")
.WithExecution(async cancellationToken =>
{
if (stable)
{
foreach (var project in srcProjects)
{
await ExecuteCommandAsync($"dotnet pack {project} -o ./artifacts/packages", cancellationToken);
}
}
else
{
var suffix = $"preview-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
foreach (var project in srcProjects)
{
await ExecuteCommandAsync(
$"dotnet pack {project} -o ./artifacts/packages --version-suffix {suffix}", cancellationToken);
}
}
if (noPush)
{
Console.WriteLine("Skip push there's noPush specified");
return;
}
if (string.IsNullOrEmpty(apiKey))
{
// try to get apiKey from environment variable
apiKey = Environment.GetEnvironmentVariable("NuGet__ApiKey");
if (string.IsNullOrEmpty(apiKey))
{
Console.WriteLine("Skip push since there's no apiKey found");
return;
}
}
// push nuget packages
foreach (var file in Directory.GetFiles("./artifacts/packages/", "*.nupkg"))
{
await ExecuteCommandAsync($"dotnet nuget push {file} -k {apiKey} --skip-duplicate", cancellationToken);
}
}))
.WithTask("Default", b => b.WithDependency("hello").WithDependency("pack"))
.Build()
.ExecuteAsync(target, InvokeHelper.GetExitToken());
构建的过程定义为了 BuildProcess
, 一个 BuildProcess
可以拥有 Setup
/Cleanup
对应的 BuildTask
, 一个 BuildTask
可以依赖于若干个其他 BuildTask
BuildProcess
/BuildTask
/BuildProcessBuilder
/BuildTaskBuilder
定义如下,仅供参考:
file sealed class BuildProcess
{
public IReadOnlyCollection<BuildTask> Tasks { get; init; } = [];
public Func<Task>? Setup { private get; init; }
public Func<Task>? Cleanup { private get; init; }
public async Task ExecuteAsync(string target, CancellationToken cancellationToken = default)
{
var task = Tasks.FirstOrDefault(x => x.Name == target);
if (task is null)
throw new InvalidOperationException("Invalid target to execute");
try
{
if (Setup != null)
await Setup.Invoke();
cancellationToken.ThrowIfCancellationRequested();
await ExecuteTask(task, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("Operation canceled");
}
finally
{
if (Cleanup != null)
await Cleanup.Invoke();
}
}
private static async Task ExecuteTask(BuildTask task, CancellationToken cancellationToken)
{
Console.WriteLine($"===== Task {task.Name} {task.Description} executing ======");
// execute dependency tasks
foreach (var dependencyTask in task.Dependencies)
{
await ExecuteTask(dependencyTask, cancellationToken);
}
// execute task
await task.ExecuteAsync(cancellationToken);
Console.WriteLine($"===== Task {task.Name} {task.Description} executed ======");
}
public static BuildProcessBuilder CreateBuilder()
{
return new BuildProcessBuilder();
}
}
file sealed class BuildProcessBuilder
{
private readonly List<BuildTask> _tasks = [];
private Func<Task>? _setup, _cleanup;
public BuildProcessBuilder WithTask(string name, Action<BuildTaskBuilder> buildTaskConfigure)
{
var buildTaskBuilder = new BuildTaskBuilder(name);
buildTaskBuilder.WithTaskFinder(s =>
_tasks.Find(t => t.Name == s) ?? throw new InvalidOperationException($"No task found with name {s}"));
buildTaskConfigure.Invoke(buildTaskBuilder);
var task = buildTaskBuilder.Build();
_tasks.Add(task);
return this;
}
public BuildProcessBuilder WithSetup(Action setupFunc)
{
_setup = setupFunc.WrapTask();
return this;
}
public BuildProcessBuilder WithSetup(Func<Task> setupFunc)
{
_setup = setupFunc;
return this;
}
public BuildProcessBuilder WithCleanup(Action cleanupFunc)
{
_cleanup = cleanupFunc.WrapTask();
return this;
}
public BuildProcessBuilder WithCleanup(Func<Task> cleanupFunc)
{
_cleanup = cleanupFunc;
return this;
}
internal BuildProcess Build()
{
return new BuildProcess() { Tasks = _tasks, Setup = _setup, Cleanup = _cleanup };
}
}
file sealed class BuildTask(string name, string? description, Func<CancellationToken, Task>? execution = null)
{
public string Name => name;
public string Description => description ?? name;
public IReadOnlyCollection<BuildTask> Dependencies { get; init; } = [];
public Task ExecuteAsync(CancellationToken cancellationToken = default) =>
execution?.Invoke(cancellationToken) ?? Task.CompletedTask;
}
file sealed class BuildTaskBuilder(string name)
{
private readonly string _name = name;
private string? _description;
private Func<CancellationToken, Task>? _execution;
private readonly List<BuildTask> _dependencies = [];
public BuildTaskBuilder WithDescription(string description)
{
_description = description;
return this;
}
public BuildTaskBuilder WithExecution(Action execution)
{
_execution = execution.WrapTask().WrapCancellation();
return this;
}
public BuildTaskBuilder WithExecution(Func<Task> execution)
{
_execution = execution.WrapCancellation();
return this;
}
public BuildTaskBuilder WithExecution(Func<CancellationToken, Task> execution)
{
_execution = execution;
return this;
}
public BuildTaskBuilder WithDependency(string dependencyTaskName)
{
if (_taskFinder is null) throw new InvalidOperationException("Dependency task name is not supported");
_dependencies.Add(_taskFinder.Invoke(dependencyTaskName));
return this;
}
private Func<string, BuildTask>? _taskFinder;
internal BuildTaskBuilder WithTaskFinder(Func<string, BuildTask> taskFinder)
{
_taskFinder = taskFinder;
return this;
}
public BuildTask Build()
{
var buildTask = new BuildTask(_name, _description, _execution) { Dependencies = _dependencies };
return buildTask;
}
}
ExecuteCommandAsync
封装了执行命令行操作,实现基于自己封装的一个 CommandExecutor
,有需要可以参考 https://github.com/WeihanLi/WeihanLi.Common/blob/dev/src/WeihanLi.Common/Helpers/CommandExecutor.cs:
async Task ExecuteCommandAsync(string commandText, CancellationToken cancellationToken = default)
{
Console.WriteLine($"Executing command: \n {commandText}");
Console.WriteLine();
var result = await CommandExecutor.ExecuteCommandAndOutputAsync(commandText, cancellationToken: cancellationToken);
result.EnsureSuccessExitCode();
Console.WriteLine();
}
你也可以自己直接基于 Process
类型进行封装或者基于 CliWrap
来实现
async Task ExecuteCommandAsync(string commandText, KeyValuePair<string, string>[]? replacements = null)
{
var commandTextWithReplacements = commandText;
if (replacements is { Length: > 0})
{
foreach (var item in replacements)
{
commandTextWithReplacements = commandTextWithReplacements.Replace(item.Value, item.Key);
}
}
Console.WriteLine($"Executing command: \n {commandTextWithReplacements}");
Console.WriteLine();
var splits = commandText.Split([' '], 2);
var result = await Cli.Wrap(splits[0])
.WithArguments(splits.Length > 1 ? splits[1] : string.Empty)
.WithStandardErrorPipe(PipeTarget.ToStream(Console.OpenStandardError()))
.WithStandardOutputPipe(PipeTarget.ToStream(Console.OpenStandardOutput()))
.ExecuteAsync();
Console.WriteLine();
Console.WriteLine($"ExitCode: {result.ExitCode} ElapsedTime: {result.RunTime}");
}
注意的话会发现这个方法多了一个 replacements
方法,主要是用来替换打印出来命令行中的敏感信息,比如 api key 等信息,可以根据自己的需要进行改造
最后我们为了比较方便的自定义,提供了一些参数可以从命令行中直接获取
var target = Argument("target", "Default");
var apiKey = Argument("apiKey", "");
var stable = ArgumentBool("stable");
var noPush = ArgumentBool("noPush");
Console.WriteLine($$"""
Arguments:
target: {{target}}
stable: {{stable}}
noPush: {{noPush}}
args:
{{args.StringJoin("\n")}}
""");
bool ArgumentBool(string argumentName, bool defaultValue = default)
{
var value = ArgumentInternal(argumentName);
if (value is null) return defaultValue;
if (value == string.Empty) return true;
return bool.Parse(value);
}
string? Argument(string argumentName, string? defaultValue = default)
{
return ArgumentInternal(argumentName) ?? defaultValue;
}
string? ArgumentInternal(string argumentName)
{
for (var i = 0; i < args.Length; i++)
{
if (args[i] == $"--{argumentName}" || args[i] == $"-{argumentName}")
{
if (i + 1 == args.Length || args[i + 1].StartsWith('-'))
return string.Empty;
return args[i + 1];
}
if (args[i].StartsWith($"-{argumentName}=", StringComparison.Ordinal)
|| args[i].StartsWith($"-{argumentName}:", StringComparison.Ordinal))
return args[i][$"-{argumentName}=".Length..];
if (args[i].StartsWith($"--{argumentName}=", StringComparison.Ordinal)
|| args[i].StartsWith($"--{argumentName}:", StringComparison.Ordinal))
return args[i][$"--{argumentName}=".Length..];
}
return null;
}
最后原来 cake 的 build.ps1
和 build.sh
我们也来更新一下,之前是安装 cake dotnet tool 并执行命令,现在变成了安装 dotnet-exec
tool
build.ps1:
# Install the lastest dotnet-execute tool
dotnet tool update --global dotnet-execute --prerelease
# Execute CSharp script
Write-Host "dotnet-exec ./build/build.cs --args $ARGS" -ForegroundColor GREEN
dotnet-exec ./build/build.cs --args "$ARGS"
buil.sh
#!/bin/sh
# Install dotnet-exec tool
dotnet tool update --global dotnet-execute --prerelease
# configure dotnet tool path
export PATH="$PATH:$HOME/.dotnet/tools"
echo "dotnet-exec ./build/build.cs --args $@"
dotnet-exec ./build/build.cs --args "$@"
来看一下执行效果
dotnet-exec hello
dotnet-exec build
More
如果担心代码写起来有点麻烦可以在脚本目录下脚本一个项目文件来借助 IDE 来在有代码提示的情况下完成代码,第一个项目里我写的时候是加了一个项目文件,项目文件就是默认的项目文件,然后添加了我自己写的一个包引用,如下:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WeihanLi.Common" />
</ItemGroup>
</Project>
后面的项目就是直接拷贝过去改下项目文件路径基本就好了
改造之后,以后的维护成本就会方便很多,比如说之前 dotnet test
只是跑一下 test case,我们可以比较方便地添加命令行参数来生成代码覆盖率报告等
完整的代码可以在 Github 上找到,有兴趣的朋友可以自己研究一下哈,最后的链接是已经改造好的项目可以作为参考
最后除了 dotnet-exec 之外,也可以考虑 dotnet-script 来执行 C# 脚本
dotnet-script 的语法稍微不同,比如引用 nuget 包,而 dotnet-exec 可以相对来说好一些可以在源码基础上执行,不影响打开项目文件在 IDE 里编译
References
-
https://github.com/WeihanLi/dotnet-exec/blob/0.15.0/build.cake
-
https://github.com/WeihanLi/dotnet-exec/blob/main/build/build.cs
-
https://github.com/WeihanLi/dotnet-httpie/blob/dev/build/build.cs
-
https://github.com/WeihanLi/WeihanLi.Common/blob/dev/build/build.cs