diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index dc20097..ce0e24b 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -64,10 +64,10 @@ jobs:
run: |
if ($env:RUNNER_OS -eq "Windows") {
# CRLF
- echo "expected=67c666358a863dc30294efef391fd25363971bc82cc9c7b269a9e4ef1cef37ff" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ echo "expected=CC49FABDBC9FC0756E6BF9049CF9E494EABCF13BB31F24DF2FE1FC1A2C32FCB4" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
} else {
# LF
- echo "expected=c19fbbedce4fa5a38d3cd534e4d8c1260e9cddddcf23900428dd5d34fb6fc075" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ echo "expected=D810BE383BFA53F8BD7D8C174BB803A539FC6F224C13F349ED7B4CF559D62FED" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
}
- name: 测试 - 文件
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 1499da2..bc38458 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,6 +1,6 @@
{
"recommendations": [
"chouzz.vscode-innosetup",
- "ms-dotnettools.csharp"
+ "JetBrains.resharper-code"
]
}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..029e0ac
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,14 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "ReSharper: 启动 PinAction.csproj 项目",
+ "type": "dotnet",
+ "request": "launch",
+ "args": [
+ "${workspaceFolder}/tests/file.yaml"
+ ],
+ "projectPath": "${workspaceFolder}/src/PinAction/PinAction.csproj"
+ }
+ ]
+}
diff --git a/src/PinAction/Program.cs b/src/PinAction/Program.cs
index 5a1750f..d45f42b 100644
--- a/src/PinAction/Program.cs
+++ b/src/PinAction/Program.cs
@@ -98,10 +98,10 @@ args is ["help"
}
///
- /// 固定工作流版本到全长哈希。
+ /// 扫描指定的工作流文件,并将其中的 uses: 引用固定为对应提交的哈希值。
///
- /// 工作流文件路径
- /// 是否成功
+ /// 要处理的 YAML / YML 工作流文件路径。
+ /// 若文件处理成功返回 ;失败返回 。
private static bool PinActionHash(string path)
{
// 读取文件内容,并按行分隔
@@ -139,6 +139,7 @@ private static bool PinActionHash(string path)
AnsiConsole.MarkupLine($"{Print.MSHead.Information} {string.Format(Strings.SkippingAlreadyPinnedHashes, Markup.Escape($"{repo}@{tag}"))}");
continue;
}
+
// 检查仓库是否是 owner/repo 的格式
if (repo.Split('/').Length != 2)
{
@@ -203,7 +204,8 @@ private static bool PinActionHash(string path)
continue;
}
- lines[i] = $"{cleanLinePaths[0].Replace($"{repo}@{tag}", $"{repo}@{hash}")} # {tag}";
+ string refinedTag = ResolveRefinedTag(repo, tag, hash);
+ lines[i] = $"{cleanLinePaths[0].Replace($"{repo}@{tag}", $"{repo}@{hash}")} # {refinedTag}";
if (cleanLinePaths.Length > 1)
{
// 将注释部分重新添加到行末
@@ -213,7 +215,7 @@ private static bool PinActionHash(string path)
}
}
- AnsiConsole.MarkupLine($"{Print.MSHead.Success} {Strings.Pinned} {Markup.Escape($"{repo}@{hash}")} # {Markup.Escape(tag)}");
+ AnsiConsole.MarkupLine($"{Print.MSHead.Success} {Strings.Pinned} {Markup.Escape($"{repo}@{hash}")} # {Markup.Escape(refinedTag)}");
}
// 将修改后的内容写回文件
@@ -221,6 +223,76 @@ private static bool PinActionHash(string path)
return true;
}
+ ///
+ /// 根据目标提交哈希尝试找回与该提交关联的更合适版本标签。
+ ///
+ /// 仓库名称,格式为 owner/repo。
+ /// 原始引用的版本或分支名称。
+ /// 对应的提交 SHA,通常为 40 位十六进制值。
+ /// 若能在目标仓库中找到与提交 SHA 对应的标签,则返回最合适的标签名;否则返回原始 。
+ private static string ResolveRefinedTag(string repo, string tag, string hash)
+ {
+ if (string.IsNullOrWhiteSpace(tag) || string.IsNullOrWhiteSpace(hash))
+ {
+ return tag;
+ }
+
+ string[] repoParts = repo.Split('/', 2, StringSplitOptions.TrimEntries);
+ if (repoParts.Length != 2)
+ {
+ return tag;
+ }
+
+ try
+ {
+ IReadOnlyList tags = GitHubClient.Repository.GetAllTags(repoParts[0], repoParts[1]).Result;
+ if (tags.Count == 0)
+ {
+ return tag;
+ }
+
+ RepositoryTag? sameCommitTag = tags
+ .Where(t => string.Equals(t.Commit.Sha, hash, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(t => t.Name, Comparer.Create(CompareVersionStrings))
+ .FirstOrDefault();
+
+ return sameCommitTag is not null ? sameCommitTag.Name : tag;
+ }
+ catch
+ {
+ return tag;
+ }
+ }
+
+ ///
+ /// 比较两个版本字符串,按数字段顺序进行自然排序。
+ ///
+ /// 左侧版本字符串。
+ /// 右侧版本字符串。
+ /// 若 小于 返回负数;相等返回 0;大于返回正数。
+ private 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))];
+ int maxLen = Math.Max(leftParts.Length, rightParts.Length);
+
+ for (int i = 0; i < maxLen; i++)
+ {
+ int leftPart = i < leftParts.Length ? leftParts[i] : 0;
+ int rightPart = i < rightParts.Length ? rightParts[i] : 0;
+ int result = leftPart.CompareTo(rightPart);
+ if (result != 0)
+ {
+ return result;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// GitHub API 客户端。
+ ///
private static readonly GitHubClient GitHubClient = new(new ProductHeaderValue("PinAction"))
{
// 如果你想让请求使用 GitHub Token,可以将 Token 临时填在这里
@@ -229,15 +301,30 @@ private static bool PinActionHash(string path)
};
///
- /// 缓存已固定哈希值的 Action,第二次遇到时不用再去请求 GitHub API 获取。
- /// 按 repo@tag, hash 一对存储。
+ /// 缓存已固定哈希值的 Action,避免同一 repo@tag 重复调用 GitHub API。
+ /// 键为 repo@tag,值为对应的提交 SHA。
///
private static readonly ConcurrentDictionary PinedActions = new();
+ ///
+ /// 匹配工作流中的 uses: 引用,提取仓库名和对应版本或分支名。
+ ///
+ /// 用于解析 owner/repo@ref 形式的正则对象。
[GeneratedRegex(@"^\s*uses:\s*([^@]+)@([^@|\s]+)\s*$")]
private static partial Regex UsesRegex();
+ ///
+ /// 判断给定字符串是否为 40 位十六进制提交哈希。
+ ///
+ /// 用于验证 SHA-1 哈希格式的正则对象。
[GeneratedRegex(@"^[a-fA-F0-9]{40}$")]
private static partial Regex HashRegex();
+
+ ///
+ /// 匹配版本字符串中的数字段,供自然排序比较版本号时使用。
+ ///
+ /// 用于提取版本号中的数字序列的正则对象。
+ [GeneratedRegex(@"\d+")]
+ private static partial Regex NumberRegex();
}
}
diff --git a/tests/file.yaml b/tests/file.yaml
index f31e7c7..578c071 100644
--- a/tests/file.yaml
+++ b/tests/file.yaml
@@ -1,5 +1,5 @@
-uses: actions/checkout@v6.0.2
+uses: actions/checkout@v7
# 预期将原先的
-# uses: actions/checkout@v6.0.2
+# uses: actions/checkout@v7
# 改为
-# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+# uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1