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