diff --git a/.editorconfig b/.editorconfig
index 69fc164..9015a8b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,3 +1,6 @@
+[*]
+indent_style = space
+
[*.cs]
# 使用显式类型
csharp_style_var_for_built_in_types = false:warning
@@ -6,3 +9,6 @@ csharp_style_var_elsewhere = false:warning
# 多余限定符
resharper_redundant_name_qualifier_highlighting = hint
+
+[*.{csproj,slnx}]
+indent_size = 2
diff --git a/.github/workflows/xUnit.yaml b/.github/workflows/xUnit.yaml
new file mode 100644
index 0000000..89865f5
--- /dev/null
+++ b/.github/workflows/xUnit.yaml
@@ -0,0 +1,35 @@
+name: xUnit 单元测试
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ tests:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu-latest
+ - windows-latest
+ - macos-latest
+
+ steps:
+ - name: 检出代码
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: 设置 .NET
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: 10.x
+
+ - name: 运行测试
+ run: dotnet test "src/"
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..8f73781
--- /dev/null
+++ b/global.json
@@ -0,0 +1,5 @@
+{
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
\ No newline at end of file
diff --git a/src/PinAction.slnx b/src/PinAction.slnx
index f4dde52..ae774cc 100644
--- a/src/PinAction.slnx
+++ b/src/PinAction.slnx
@@ -1,3 +1,4 @@
+
diff --git a/src/PinAction.tests/PinAction.tests.csproj b/src/PinAction.tests/PinAction.tests.csproj
new file mode 100644
index 0000000..4fdf1d4
--- /dev/null
+++ b/src/PinAction.tests/PinAction.tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ enable
+ enable
+ Exe
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PinAction.tests/UnitTests.cs b/src/PinAction.tests/UnitTests.cs
new file mode 100644
index 0000000..055f7be
--- /dev/null
+++ b/src/PinAction.tests/UnitTests.cs
@@ -0,0 +1,277 @@
+using Moq;
+using Octokit;
+
+namespace PinAction.Tests
+{
+ ///
+ /// 定义测试集合,禁用并行化以避免对共享资源的冲突。
+ ///
+ [CollectionDefinition(nameof(ProgramTestsCollection), DisableParallelization = true)]
+ public sealed class ProgramTestsCollection;
+
+ ///
+ /// 单元测试类。
+ ///
+ [Collection(nameof(ProgramTestsCollection))]
+ public sealed class UnitTests : IDisposable
+ {
+ ///
+ /// 用于测试的固定提交 SHA 值。
+ ///
+ private const string CommitSha = "0123456789abcdef0123456789abcdef01234567";
+
+ ///
+ /// 用于测试的临时目录路径。
+ ///
+ private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory().FullName;
+
+ ///
+ /// 原始的 GitHub 客户端实例,用于在测试完成后恢复。
+ ///
+ private readonly IGitHubClient _originalGitHubClient = Program.GitHubClient;
+
+ ///
+ /// 用于测试的 GitHub 客户端模拟实例。
+ ///
+ private readonly Mock _githubClient = new(MockBehavior.Strict);
+
+ ///
+ /// 用于测试的 Git 数据库客户端模拟实例。
+ ///
+ private readonly Mock _gitDatabase = new(MockBehavior.Strict);
+
+ ///
+ /// 用于测试的 Git 标签客户端模拟实例。
+ ///
+ private readonly Mock _tags = new(MockBehavior.Strict);
+
+ ///
+ /// 用于测试的引用客户端模拟实例。
+ ///
+ private readonly Mock _references = new(MockBehavior.Strict);
+
+ ///
+ /// 用于测试的仓库客户端模拟实例。
+ ///
+ private readonly Mock _repositories = new(MockBehavior.Strict);
+
+ ///
+ /// 初始化单元测试类的构造函数。
+ ///
+ public UnitTests()
+ {
+ // 设置 GitHub 客户端的模拟行为
+ _gitDatabase.Setup(client => client.Reference).Returns(_references.Object);
+ _gitDatabase.Setup(client => client.Tag).Returns(_tags.Object);
+ _githubClient.Setup(client => client.Git).Returns(_gitDatabase.Object);
+ _githubClient.Setup(client => client.Repository).Returns(_repositories.Object);
+
+ Program.PinedActions.Clear();
+ Program.GitHubClient = _githubClient.Object;
+ }
+
+ [Fact(DisplayName = "CompareVersionStrings 方法应按自然顺序比较版本字符串")]
+ public void CompareVersionStrings_OrdersNumericSegmentsNaturally()
+ {
+ Assert.True(Program.CompareVersionStrings("v1.9.0", "v1.10.0") < 0);
+ Assert.True(Program.CompareVersionStrings("v2.0", "v1.99") > 0);
+ Assert.Equal(0, Program.CompareVersionStrings("release-1.2", "v1.2.0"));
+ }
+
+ [Fact(DisplayName = "ResolveRefinedTag 方法应返回与提交匹配的最高版本标签")]
+ public void ResolveRefinedTag_ReturnsHighestVersionForMatchingCommit()
+ {
+ _repositories
+ .Setup(client => client.GetAllTags("owner", "repo"))
+ .Returns(Task.FromResult>(
+ [
+ CreateRepositoryTag("v1.9.0", CommitSha),
+ CreateRepositoryTag("v1.10.0", CommitSha),
+ CreateRepositoryTag("v99.0.0", "ffffffffffffffffffffffffffffffffffffffff")
+ ]));
+
+ string result = Program.ResolveRefinedTag("owner/repo", "main", CommitSha);
+
+ Assert.Equal("v1.10.0", result);
+ _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once);
+ }
+
+ [Fact(DisplayName = "ResolveRefinedTag 方法应在提交没有匹配标签时返回原始标签")]
+ public void ResolveRefinedTag_ReturnsOriginalTagWhenCommitHasNoMatchingTag()
+ {
+ _repositories
+ .Setup(client => client.GetAllTags("owner", "repo"))
+ .Returns(Task.FromResult>(
+ [
+ CreateRepositoryTag("v1.0.0", "ffffffffffffffffffffffffffffffffffffffff")
+ ]));
+
+ string result = Program.ResolveRefinedTag("owner/repo", "main", CommitSha);
+
+ Assert.Equal("main", result);
+ }
+
+ [Fact(DisplayName = "PinActionHash 方法应正确固定引用并保留内联注释")]
+ public void PinActionHash_PinsReferencesAndPreservesInlineComments()
+ {
+ string path = Path.Combine(_temporaryDirectory, "workflow.yml");
+ File.WriteAllLines(path,
+ [
+ "name: test",
+ " uses: owner/repo@v1 # keep this comment",
+ " uses: owner/repo@v1",
+ " run: echo uses: not-an-action"
+ ]);
+
+ _references
+ .Setup(client => client.Get("owner", "repo", "tags/v1"))
+ .Returns(Task.FromResult(CreateReference(CommitSha)));
+ _repositories
+ .Setup(client => client.GetAllTags("owner", "repo"))
+ .Returns(Task.FromResult>(
+ [
+ CreateRepositoryTag("v1.2.0", CommitSha)
+ ]));
+
+ bool succeeded = Program.PinActionHash(path);
+
+ Assert.True(succeeded);
+ string[] expectedLines =
+ [
+ "name: test",
+ $" uses: owner/repo@{CommitSha} # v1.2.0 keep this comment", // # 前面的空格数应保持原样
+ $" uses: owner/repo@{CommitSha} # v1.2.0", // 默认两个空格
+ " run: echo uses: not-an-action"
+ ];
+ Assert.Equal(expectedLines, File.ReadAllLines(path));
+ _references.Verify(client => client.Get("owner", "repo", "tags/v1"), Times.Once);
+ _tags.Verify(client => client.Get(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once);
+ }
+
+ [Fact(DisplayName = "PinActionHash 方法应将附注标签对象解析为其目标提交哈希")]
+ public void PinActionHash_ResolvesAnnotatedTagToCommitHash()
+ {
+ const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ string path = Path.Combine(_temporaryDirectory, "workflow.yml");
+ File.WriteAllText(path, " uses: owner/repo@v1");
+
+ _references
+ .Setup(client => client.Get("owner", "repo", "tags/v1"))
+ .Returns(Task.FromResult(CreateReference(tagObjectSha, TaggedType.Tag)));
+ _tags
+ .Setup(client => client.Get("owner", "repo", tagObjectSha))
+ .Returns(Task.FromResult(CreateTagObject(CommitSha, TaggedType.Commit)));
+ _repositories
+ .Setup(client => client.GetAllTags("owner", "repo"))
+ .Returns(Task.FromResult>([]));
+
+ bool succeeded = Program.PinActionHash(path);
+
+ Assert.True(succeeded);
+ Assert.Equal($" uses: owner/repo@{CommitSha} # v1", File.ReadAllText(path).TrimEnd('\r', '\n'));
+ _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once);
+ }
+
+ [Fact(DisplayName = "PinActionHash 方法应跳过最终目标不是提交的附注标签")]
+ public void PinActionHash_SkipsAnnotatedTagWhenResolvedTargetIsNotCommit()
+ {
+ const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const string treeSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ string path = Path.Combine(_temporaryDirectory, "workflow.yml");
+ const string originalLine = " uses: owner/repo@v1";
+ File.WriteAllText(path, originalLine);
+
+ _references
+ .Setup(client => client.Get("owner", "repo", "tags/v1"))
+ .Returns(Task.FromResult(CreateReference(tagObjectSha, TaggedType.Tag)));
+ _tags
+ .Setup(client => client.Get("owner", "repo", tagObjectSha))
+ .Returns(Task.FromResult(CreateTagObject(treeSha, TaggedType.Tree)));
+
+ bool result = Program.PinActionHash(path);
+
+ Assert.Equal(originalLine, File.ReadAllText(path).TrimEnd('\r', '\n'));
+ Assert.False(Program.PinedActions.ContainsKey("owner/repo@v1"));
+ Assert.False(result);
+ _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once);
+ _repositories.Verify(client => client.GetAllTags(
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact(DisplayName = "PinActionHash 方法应跳过已固定的和无效的仓库引用")]
+ public void PinActionHash_SkipsAlreadyPinnedAndInvalidRepositoryReferences()
+ {
+ string path = Path.Combine(_temporaryDirectory, "workflow.yml");
+ string[] originalLines =
+ [
+ $" uses: owner/repo@{CommitSha}",
+ " uses: local-action@v1",
+ " uses: owner/repo/path@v1"
+ ];
+ File.WriteAllLines(path, originalLines);
+
+ bool succeeded = Program.PinActionHash(path);
+
+ Assert.True(succeeded);
+ Assert.Equal(originalLines, File.ReadAllLines(path));
+ _references.Verify(client => client.Get(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ _repositories.Verify(client => client.GetAllTags(
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// 还原单元测试使用的资源。
+ ///
+ public void Dispose()
+ {
+ Program.PinedActions.Clear();
+ Program.GitHubClient = _originalGitHubClient;
+ Directory.Delete(_temporaryDirectory, recursive: true);
+ }
+
+ ///
+ /// 创建一个引用实例。
+ ///
+ /// 提交的 SHA。
+ /// 目标对象的类型。
+ /// 引用实例。
+ private static Reference CreateReference(string sha, TaggedType type = TaggedType.Commit)
+ {
+ TagObject target = new("", "", "", "", sha, null, null, type);
+ return new Reference("refs/tags/v1", "", "", target);
+ }
+
+ ///
+ /// 创建一个附注标签对象实例。
+ ///
+ /// 目标对象的 SHA。
+ /// 目标对象的类型。
+ /// 附注标签对象实例。
+ private static GitTag CreateTagObject(string sha, TaggedType type)
+ {
+ TagObject target = new("", "", "", "", sha, null, null, type);
+ return new GitTag("", "", "", "", "", null, null, "", "", null, target, null);
+ }
+
+ ///
+ /// 创建一个仓库标签实例。
+ ///
+ /// 标签的名称。
+ /// 提交的 SHA。
+ /// 仓库标签实例。
+ private static RepositoryTag CreateRepositoryTag(string name, string sha)
+ {
+ GitReference commit = new("", "", "", "", sha, null!, null!);
+ return new RepositoryTag(name, "", commit, "", "");
+ }
+ }
+}
diff --git a/src/PinAction.tests/xunit.runner.json b/src/PinAction.tests/xunit.runner.json
new file mode 100644
index 0000000..86c7ea0
--- /dev/null
+++ b/src/PinAction.tests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}
diff --git a/src/PinAction/PinAction.csproj b/src/PinAction/PinAction.csproj
index 63b343b..9f35c49 100644
--- a/src/PinAction/PinAction.csproj
+++ b/src/PinAction/PinAction.csproj
@@ -23,6 +23,10 @@
+
+
+
+
/
diff --git a/src/PinAction/Program.cs b/src/PinAction/Program.cs
index d45f42b..2662bef 100644
--- a/src/PinAction/Program.cs
+++ b/src/PinAction/Program.cs
@@ -102,7 +102,7 @@ args is ["help"
///
/// 要处理的 YAML / YML 工作流文件路径。
/// 若文件处理成功返回 ;失败返回 。
- private static bool PinActionHash(string path)
+ internal static bool PinActionHash(string path)
{
// 读取文件内容,并按行分隔
string[] lines = File.ReadAllLines(path);
@@ -230,7 +230,7 @@ private static bool PinActionHash(string path)
/// 原始引用的版本或分支名称。
/// 对应的提交 SHA,通常为 40 位十六进制值。
/// 若能在目标仓库中找到与提交 SHA 对应的标签,则返回最合适的标签名;否则返回原始 。
- private static string ResolveRefinedTag(string repo, string tag, string hash)
+ internal static string ResolveRefinedTag(string repo, string tag, string hash)
{
if (string.IsNullOrWhiteSpace(tag) || string.IsNullOrWhiteSpace(hash))
{
@@ -270,7 +270,7 @@ private static string ResolveRefinedTag(string repo, string tag, string hash)
/// 左侧版本字符串。
/// 右侧版本字符串。
/// 若 小于 返回负数;相等返回 0;大于返回正数。
- private static int CompareVersionStrings(string left, string right)
+ internal static int CompareVersionStrings(string left, string right)
{
int[] leftParts = [.. NumberRegex().Matches(left).Select(m => int.Parse(m.Value))];
int[] rightParts = [.. NumberRegex().Matches(right).Select(m => int.Parse(m.Value))];
@@ -293,7 +293,7 @@ private static int CompareVersionStrings(string left, string right)
///
/// GitHub API 客户端。
///
- private static readonly GitHubClient GitHubClient = new(new ProductHeaderValue("PinAction"))
+ internal static IGitHubClient GitHubClient { get; set; } = new GitHubClient(new ProductHeaderValue("PinAction"))
{
// 如果你想让请求使用 GitHub Token,可以将 Token 临时填在这里
// 记得用完后 撤销/删除/轮换 Token。
@@ -304,7 +304,7 @@ private static int CompareVersionStrings(string left, string right)
/// 缓存已固定哈希值的 Action,避免同一 repo@tag 重复调用 GitHub API。
/// 键为 repo@tag,值为对应的提交 SHA。
///
- private static readonly ConcurrentDictionary PinedActions = new();
+ internal static readonly ConcurrentDictionary PinedActions = new();
///
/// 匹配工作流中的 uses: 引用,提取仓库名和对应版本或分支名。