From d93addf3a433fc39a5eb4b20a857f16da4c2b269 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 7 Apr 2025 10:38:58 +1000 Subject: [PATCH 1/7] Switch from using powershell to ktsu.RunCommand --- CrossRepoActions/Dotnet.cs | 207 ++++++++--------------- CrossRepoActions/Git.cs | 82 ++++----- CrossRepoActions/PowershellExtensions.cs | 137 --------------- CrossRepoActions/Verbs/GitPull.cs | 24 ++- CrossRepoActions/Verbs/UpdatePackages.cs | 9 +- 5 files changed, 128 insertions(+), 331 deletions(-) delete mode 100644 CrossRepoActions/PowershellExtensions.cs diff --git a/CrossRepoActions/Dotnet.cs b/CrossRepoActions/Dotnet.cs index 13bc402..fd1827c 100644 --- a/CrossRepoActions/Dotnet.cs +++ b/CrossRepoActions/Dotnet.cs @@ -3,108 +3,80 @@ namespace ktsu.CrossRepoActions; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Management.Automation; using System.Text.Json.Nodes; using DustInTheWind.ConsoleTools.Controls.Spinners; using ktsu.Extensions; +using ktsu.RunCommand; using ktsu.StrongPaths; -using NuGet.Versioning; - internal static class Dotnet { internal static Collection BuildSolution() { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("build") - .AddArgument("--nologo") - .InvokeAndReturnOutput(); + Collection results = []; + + RunCommand.Execute("dotnet build --nologo", new LineOutputHandler(results.Add, results.Add)); return GetErrors(results); } internal static Collection BuildProject(AbsoluteFilePath projectFile) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("build") - .AddArgument("--nologo") - .AddArgument(projectFile.ToString()) - .InvokeAndReturnOutput(); + Collection results = []; + + RunCommand.Execute($"dotnet build --nologo {projectFile}", new LineOutputHandler(results.Add, results.Add)); return GetErrors(results); } internal static Collection RunTests() { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("vstest") - .AddArgument("**/bin/**/*Test.dll") - .AddArgument("/logger:console;verbosity=normal") - .AddArgument("--nologo") - .InvokeAndReturnOutput(PowershellStreams.All); - - return results; + Collection results = []; + + RunCommand.Execute($"dotnet vstest **/bin/**/*Test.dll --logger:console;verbosity=normal --nologo", new LineOutputHandler(results.Add, results.Add)); + + return GetErrors(results); } internal static Collection GetTests() { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("vstest") - .AddArgument("--ListTests") - .AddArgument("--nologo") - .AddArgument("**/bin/**/*Test.dll") - .InvokeAndReturnOutput(); - - var stringResults = results + Collection results = []; + + RunCommand.Execute($"dotnet vstest --ListTests --nologo **/bin/**/*Test.dll", new LineOutputHandler(results.Add, results.Add)); + + var filteredResults = results .Where(r => !r.StartsWith("The following") && !r.StartsWith("No test source")) .ToCollection(); - return stringResults; + return filteredResults; } internal static Collection GetProjects(AbsoluteFilePath solutionFile) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("sln") - .AddArgument(solutionFile.ToString()) - .AddArgument("list") - .InvokeAndReturnOutput(); - - var stringResults = results + Collection results = []; + + RunCommand.Execute($"dotnet sln {solutionFile} list", new LineOutputHandler(results.Add, results.Add)); + + var filteredResults = results .Where(r => r.EndsWithOrdinal(".csproj")) .ToCollection(); - return stringResults; + return filteredResults; } internal static Collection GetSolutionDependencies(AbsoluteFilePath solutionFile) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("list") - .AddArgument(solutionFile.ToString()) - .AddArgument("package") - .AddArgument("--include-transitive") - .InvokeAndReturnOutput(); - - var stringResults = results + Collection results = []; + + RunCommand.Execute($"dotnet list {solutionFile} package --include-transitive", new LineOutputHandler(results.Add, results.Add)); + + var filteredResults = results .Where(r => r.StartsWithOrdinal(">")) .ToCollection(); - var dependencies = stringResults + var dependencies = filteredResults .Select(r => { string[] parts = r.Split(' '); @@ -119,26 +91,19 @@ internal static Collection GetSolutionDependencies(AbsoluteFilePath sol return dependencies; } + private const string packageJsonError = "Could not parse JSON output from 'dotnet list package --format-json'"; internal static Collection GetOutdatedProjectDependencies(AbsoluteFilePath projectFile) { - using var ps = PowerShell.Create(); - var jsonResult = ps - .AddCommand("dotnet") - .AddArgument("list") - .AddArgument(projectFile.ToString()) - .AddArgument("package") - .AddArgument("--outdated") - .AddArgument("--format=json") - .InvokeAndReturnOutput(); - - const string jsonError = "Could not parse JSON output from 'dotnet list package --outdated --format-json'"; - - string jsonString = string.Join("", jsonResult); + Collection results = []; + + RunCommand.Execute($"dotnet list {projectFile} package --format=json", new LineOutputHandler(results.Add, results.Add)); + + string jsonString = string.Join("", results); var rootObject = JsonNode.Parse(jsonString)?.AsObject() - ?? throw new InvalidDataException(jsonError); + ?? throw new InvalidDataException(packageJsonError); var projects = rootObject["projects"]?.AsArray() - ?? throw new InvalidDataException(jsonError); + ?? throw new InvalidDataException(packageJsonError); var frameworks = projects.Where(p => { @@ -148,65 +113,46 @@ internal static Collection GetOutdatedProjectDependencies(AbsoluteFileP .SelectMany(p => { return p?.AsObject()?["frameworks"]?.AsArray() - ?? throw new InvalidDataException(jsonError); + ?? throw new InvalidDataException(packageJsonError); }); var packages = frameworks.SelectMany(f => { return (f as JsonObject)?["topLevelPackages"]?.AsArray() - ?? throw new InvalidDataException(jsonError); - }) - .Select(p => - { - string name = p?["id"]?.AsValue().GetValue() - ?? throw new InvalidDataException(jsonError); - - string version = p?["requestedVersion"]?.AsValue().GetValue() - ?? throw new InvalidDataException(jsonError); - - return new Package() - { - Name = name, - Version = version, - }; + ?? throw new InvalidDataException(packageJsonError); }) + .Select(ExtractPackageFromJsonNode) .DistinctBy(p => p.Name) .ToCollection(); return packages; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "False positive: we're using a using declaration")] + private static Package ExtractPackageFromJsonNode(JsonNode? p) + { + string name = p?["id"]?.AsValue().GetValue() + ?? throw new InvalidDataException(packageJsonError); + + string version = p?["requestedVersion"]?.AsValue().GetValue() + ?? throw new InvalidDataException(packageJsonError); + + return new Package() + { + Name = name, + Version = version, + }; + } + internal static Collection UpdatePackages(AbsoluteFilePath projectFile, IEnumerable packages) { var output = new Collection(); foreach (var package in packages) { - bool isPreRelease = NuGetVersion.Parse(package.Version).IsPrerelease; - using var ps = PowerShell.Create(); - if (isPreRelease) - { - var results = ps - .AddCommand("dotnet") - .AddArgument("add") - .AddArgument(projectFile.ToString()) - .AddArgument("package") - .AddArgument(package.Name) - .AddArgument("--prerelease") - .InvokeAndReturnOutput(); - output.AddMany(results); - } - else - { - var results = ps - .AddCommand("dotnet") - .AddArgument("add") - .AddArgument(projectFile.ToString()) - .AddArgument("package") - .AddArgument(package.Name) - .InvokeAndReturnOutput(); - output.AddMany(results); - } + Collection results = []; + string pre = package.Version.Contains('-') ? "--prerelease" : ""; + RunCommand.Execute($"dotnet add {projectFile} package {package.Name} {pre}", new LineOutputHandler(results.Add, results.Add)); + + output.AddMany(results); } return output; @@ -214,39 +160,27 @@ internal static Collection UpdatePackages(AbsoluteFilePath projectFile, internal static string GetProjectAssemblyName(AbsoluteFilePath projectFile) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("msbuild") - .AddArgument(projectFile.ToString()) - .AddArgument("-getProperty:AssemblyName") - .InvokeAndReturnOutput(); + Collection results = []; + + RunCommand.Execute($"dotnet msbuild {projectFile} -getProperty:AssemblyName", new LineOutputHandler(results.Add, results.Add)); return results.First(); } internal static string GetProjectVersion(AbsoluteFilePath projectFile) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("msbuild") - .AddArgument(projectFile.ToString()) - .AddArgument("-getProperty:Version") - .InvokeAndReturnOutput(); + Collection results = []; + + RunCommand.Execute($"dotnet msbuild {projectFile} -getProperty:Version", new LineOutputHandler(results.Add, results.Add)); return results.First(); } internal static bool IsProjectPackable(AbsoluteFilePath projectFile) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("dotnet") - .AddArgument("msbuild") - .AddArgument(projectFile.ToString()) - .AddArgument("-getProperty:IsPackable") - .InvokeAndReturnOutput(); + Collection results = []; + + RunCommand.Execute($"dotnet msbuild {projectFile} -getProperty:IsPackable", new LineOutputHandler(results.Add, results.Add)); try { @@ -285,6 +219,7 @@ internal static Collection DiscoverSolutionDependencies(IEnumerable { var projects = GetProjects(solutionFile) diff --git a/CrossRepoActions/Git.cs b/CrossRepoActions/Git.cs index d9e5f5d..f0e956f 100644 --- a/CrossRepoActions/Git.cs +++ b/CrossRepoActions/Git.cs @@ -1,10 +1,11 @@ namespace ktsu.CrossRepoActions; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; -using System.Management.Automation; using ktsu.Extensions; +using ktsu.RunCommand; using ktsu.StrongPaths; internal static class Git @@ -30,88 +31,63 @@ internal static IEnumerable DiscoverRepositories(Absolute internal static IEnumerable Pull(AbsoluteDirectoryPath repo) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("pull") - .AddArgument("--all") - .AddArgument("-v") - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; + + RunCommand.Execute($"git -C {repo} pull --all -v", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); return results; } internal static IEnumerable Push(AbsoluteDirectoryPath repo) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("push") - .AddArgument("-v") - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; + + RunCommand.Execute($"git -C {repo} push -v", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); return results; } internal static IEnumerable Status(AbsoluteDirectoryPath repo, AbsoluteFilePath filePath) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("status") - .AddArgument("--short") - .AddArgument("--") - .AddArgument(filePath.ToString()) - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; + + RunCommand.Execute($"git -C {repo} status --short -- {filePath}", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); return results; } internal static IEnumerable Unstage(AbsoluteDirectoryPath repo) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("restore") - .AddArgument("--staged") - .AddArgument(repo.ToString()) - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; + + RunCommand.Execute($"git -C {repo} restore --staged {repo}", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); return results; } internal static IEnumerable Add(AbsoluteDirectoryPath repo, AbsoluteFilePath filePath) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("add") - .AddArgument(filePath.ToString()) - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; + + RunCommand.Execute($"git -C {repo} add {filePath}", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); return results; } internal static IEnumerable Commit(AbsoluteDirectoryPath repo, string message) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("commit") - .AddParameter("-m", message) - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; + + RunCommand.Execute($"git -C {repo} commit -m {message}", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); + + return results; + } + + internal static IEnumerable BranchRemote(AbsoluteDirectoryPath repo) + { + Collection results = []; + + RunCommand.Execute($"git -C {repo} branch --remote", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); return results; } diff --git a/CrossRepoActions/PowershellExtensions.cs b/CrossRepoActions/PowershellExtensions.cs deleted file mode 100644 index 9525551..0000000 --- a/CrossRepoActions/PowershellExtensions.cs +++ /dev/null @@ -1,137 +0,0 @@ -namespace ktsu.CrossRepoActions; - -using System; -using System.Collections.ObjectModel; -using System.Management.Automation; - -using ktsu.Extensions; - -[Flags] -internal enum PowershellStreams -{ - Verbose = 1 << 0, - Error = 1 << 1, - Output = 1 << 2, - Debug = 1 << 3, - Warning = 1 << 4, - Information = 1 << 5, - Progress = 1 << 6, - Default = Output | Error, - All = Verbose | Error | Output | Debug | Warning | Information | Progress -} - -internal static class PowershellExtensions -{ - internal static Collection InvokeAndReturnOutput(this PowerShell ps, PowershellStreams streams = PowershellStreams.Default) - { - using var input = new PSDataCollection(); - input.Complete(); - - var collectedOutput = new Collection(); - - using var stdOutput = new PSDataCollection(); - if (streams.HasFlag(PowershellStreams.Output)) - { - stdOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - collectedOutput.Add(newRecord.ToString()); - } - }; - } - - if (streams.HasFlag(PowershellStreams.Verbose)) - { - var verboseOutput = new PSDataCollection(); - verboseOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - collectedOutput.Add(newRecord.Message); - } - }; - ps.Streams.Verbose = verboseOutput; - } - - if (streams.HasFlag(PowershellStreams.Error)) - { - var errorOutput = new PSDataCollection(); - errorOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - collectedOutput.Add(newRecord.ToString()); - } - }; - ps.Streams.Error = errorOutput; - } - - if (streams.HasFlag(PowershellStreams.Warning)) - { - var warningOutput = new PSDataCollection(); - warningOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - collectedOutput.Add(newRecord.Message); - } - }; - ps.Streams.Warning = warningOutput; - } - - if (streams.HasFlag(PowershellStreams.Information)) - { - var informationOutput = new PSDataCollection(); - informationOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - string? dataString = newRecord.MessageData?.ToString(); - if (dataString is not null) - { - collectedOutput.Add(dataString); - } - } - }; - ps.Streams.Information = informationOutput; - } - - if (streams.HasFlag(PowershellStreams.Progress)) - { - var progressOutput = new PSDataCollection(); - progressOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - collectedOutput.Add(newRecord.StatusDescription); - } - }; - ps.Streams.Progress = progressOutput; - } - - if (streams.HasFlag(PowershellStreams.Debug)) - { - var debugOutput = new PSDataCollection(); - debugOutput.DataAdded += (s, e) => - { - if (s is PSDataCollection data) - { - var newRecord = data[e.Index]; - collectedOutput.Add(newRecord.Message); - } - }; - ps.Streams.Debug = debugOutput; - } - - ps.Invoke(input, stdOutput); - - return collectedOutput.Select(s => s.Trim()).ToCollection(); - } -} diff --git a/CrossRepoActions/Verbs/GitPull.cs b/CrossRepoActions/Verbs/GitPull.cs index 0b87365..c750893 100644 --- a/CrossRepoActions/Verbs/GitPull.cs +++ b/CrossRepoActions/Verbs/GitPull.cs @@ -19,13 +19,31 @@ internal override void Run(GitPull options) }, repo => { - var output = Git.Pull(repo); - //output.WriteItemsToConsole(); + // strip branch names from output because they could get confused with errors if they contain the word "error" + string[] remoteBranches = [.. Git.BranchRemote(repo).Select(b => b.RemovePrefix("origin/"))]; + + string[] output = [.. Git.Pull(repo).Select(s => + { + string sanitized = s; + foreach (string branch in remoteBranches) + { + sanitized = sanitized.Replace("\t", " "); + while (sanitized.Contains(" ")) + { + sanitized = sanitized.Replace(" ", " "); + } + + sanitized = sanitized.Replace($"{branch} -> origin/{branch}", ""); + } + + return sanitized; + })]; if (output.Any(s => s.Contains("error"))) { string error = $"❌ {System.IO.Path.GetFileName(repo)}"; - errorSummary.Add(error); + string errorOutput = string.Join("\n", output); + errorSummary.Add($"{error} - {errorOutput}"); Console.WriteLine(error); } else diff --git a/CrossRepoActions/Verbs/UpdatePackages.cs b/CrossRepoActions/Verbs/UpdatePackages.cs index 8ee1f52..2ba6797 100644 --- a/CrossRepoActions/Verbs/UpdatePackages.cs +++ b/CrossRepoActions/Verbs/UpdatePackages.cs @@ -99,7 +99,12 @@ internal override void Run(UpdatePackages options) } }); - if (!errorSummary.IsEmpty) + if (errorSummary.IsEmpty) + { + Console.WriteLine(); + Console.WriteLine("All packages updated successfully!"); + } + else { Console.WriteLine(); Console.WriteLine("Failed to update:"); @@ -107,7 +112,7 @@ internal override void Run(UpdatePackages options) errorSummary.WriteItemsToConsole(); } - Thread.Sleep(1000 * 60 * 5); + //Thread.Sleep(1000 * 60 * 5); } } } From 3d842007bfe5da1f9fc65d1b218870d9d42bde67 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 22 Jul 2025 20:57:34 +1000 Subject: [PATCH 2/7] icno --- icon.png | Bin 16069 -> 130 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/icon.png b/icon.png index 91246f6de58218048776e760c1d1a347d5bac3a3..4372ef9bedd8e408d12e1681f6e13cf5dde54a87 100644 GIT binary patch literal 130 zcmWN?K@!3s3;@78uiyg~6KJ6Q4S^ucsC0z(;OliSd&y_C{?>KQW9-H}+Ppo>SpL_O zm(*Wo9Foihs<*61jkwaDoHz$IWU(AQ6`vT<=D6A{#3EKa@|Ft*U`YXx12N`E1!64L MtI@uqf$p@a9~!qPuK)l5 literal 16069 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 From e72b75e94c1bd2a542c8c39f674d5e870f7218e8 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 22 Jul 2025 21:01:25 +1000 Subject: [PATCH 3/7] Merge from main --- .gitignore | 14 + .mailmap | 2 +- .runsettings | 136 +- CrossRepoActions/Dotnet.cs | 40 +- CrossRepoActions/Git.cs | 12 +- CrossRepoActions/Verbs/BuildAndTest.cs | 18 +- CrossRepoActions/Verbs/GitPull.cs | 4 +- CrossRepoActions/Verbs/Menu.cs | 11 +- CrossRepoActions/Verbs/UpdatePackages.cs | 8 +- scripts/PSBuild.psm1 | 2425 ++++++++++++++++++++++ 10 files changed, 2495 insertions(+), 175 deletions(-) create mode 100644 scripts/PSBuild.psm1 diff --git a/.gitignore b/.gitignore index 31d1aef..606e270 100644 --- a/.gitignore +++ b/.gitignore @@ -631,3 +631,17 @@ Icon Network Trash Folder Temporary Items .apdisk + +.obsidian/* +!.obsidian/core-plugins.json +!.obsidian/community-plugins.json + +# SpecStory explanation file +.specstory/.what-is-this.md +# SpecStory project identity file +/.project.json +# SpecStory explanation file +/.what-is-this.md +# SpecStory derived-cursor-rules.mdc backup files +/ai_rules_backups/* +.specstory/ai_rules_backups/* \ No newline at end of file diff --git a/.mailmap b/.mailmap index 511ea60..7047021 100644 --- a/.mailmap +++ b/.mailmap @@ -7,4 +7,4 @@ github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> ktsu[bot] ktsu[bot] -Damon3000s \ No newline at end of file +Damon3000s diff --git a/.runsettings b/.runsettings index d87d642..46ce004 100644 --- a/.runsettings +++ b/.runsettings @@ -1,134 +1,8 @@ - - - - 0 - - .\TestResults + + + .\coverage + - - - - - - - - - - - - - - - - - - 100000 - - - - true - - - - - - - - - - - - - - - json,cobertura,lcov,teamcity,opencover - - - - - Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute - - - - false - - true - true - false - MissingAll,MissingAny,None - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/CrossRepoActions/Dotnet.cs b/CrossRepoActions/Dotnet.cs index fd1827c..f4f3c8c 100644 --- a/CrossRepoActions/Dotnet.cs +++ b/CrossRepoActions/Dotnet.cs @@ -105,9 +105,9 @@ internal static Collection GetOutdatedProjectDependencies(AbsoluteFileP var projects = rootObject["projects"]?.AsArray() ?? throw new InvalidDataException(packageJsonError); - var frameworks = projects.Where(p => + IEnumerable frameworks = projects.Where(p => { - var pObj = p?.AsObject(); + JsonObject? pObj = p?.AsObject(); return pObj?["frameworks"]?.AsArray() != null; }) .SelectMany(p => @@ -116,7 +116,7 @@ internal static Collection GetOutdatedProjectDependencies(AbsoluteFileP ?? throw new InvalidDataException(packageJsonError); }); - var packages = frameworks.SelectMany(f => + Collection packages = frameworks.SelectMany(f => { return (f as JsonObject)?["topLevelPackages"]?.AsArray() ?? throw new InvalidDataException(packageJsonError); @@ -145,8 +145,8 @@ private static Package ExtractPackageFromJsonNode(JsonNode? p) internal static Collection UpdatePackages(AbsoluteFilePath projectFile, IEnumerable packages) { - var output = new Collection(); - foreach (var package in packages) + Collection output = []; + foreach (Package package in packages) { Collection results = []; string pre = package.Version.Contains('-') ? "--prerelease" : ""; @@ -209,10 +209,10 @@ internal static Collection GetErrors(IEnumerable strings) => private static object ConsoleLock { get; } = new(); internal static Collection DiscoverSolutionDependencies(IEnumerable solutionFiles) { - var solutionFileCollection = solutionFiles.ToCollection(); - var solutions = new ConcurrentBag(); + Collection solutionFileCollection = solutionFiles.ToCollection(); + ConcurrentBag solutions = []; - var progressBar = new ProgressBar(); + ProgressBar progressBar = new(); progressBar.Display(); _ = Parallel.ForEach(solutionFileCollection, new() @@ -222,18 +222,18 @@ internal static Collection DiscoverSolutionDependencies(IEnumerable { - var projects = GetProjects(solutionFile) + Collection projects = GetProjects(solutionFile) .Select(p => solutionFile.DirectoryPath / p.As()) .ToCollection(); - var packages = projects + Collection packages = projects .Where(p => IsProjectPackable(p)) .Select(p => GetProjectPackage(p)) .ToCollection(); - var dependencies = GetSolutionDependencies(solutionFile); + Collection dependencies = GetSolutionDependencies(solutionFile); - var solution = new Solution() + Solution solution = new() { Name = Path.GetFileNameWithoutExtension(solutionFile.FileName), Path = solutionFile, @@ -258,20 +258,20 @@ internal static Collection DiscoverSolutionDependencies(IEnumerable SortSolutionsByDependencies(ICollection solutions) { - var unsatisfiedSolutions = solutions.ToCollection(); - var sortedSolutions = new Collection(); + Collection unsatisfiedSolutions = solutions.ToCollection(); + Collection sortedSolutions = []; while (unsatisfiedSolutions.Count != 0) { - var unsatisfiedPackages = unsatisfiedSolutions + Collection unsatisfiedPackages = unsatisfiedSolutions .SelectMany(s => s.Packages) .ToCollection(); - var satisfied = unsatisfiedSolutions + Collection satisfied = unsatisfiedSolutions .Where(s => !s.Dependencies.IntersectBy(unsatisfiedPackages.Select(p => p.Name), p => p.Name).Any()) .ToCollection(); - foreach (var solution in satisfied) + foreach (Solution? solution in satisfied) { unsatisfiedSolutions.Remove(solution); sortedSolutions.Add(solution); @@ -291,7 +291,7 @@ internal static Collection DiscoverSolutionFiles(AbsoluteDirec internal static Collection DiscoverSolutions(AbsoluteDirectoryPath root) { - var persistentState = PersistentState.Get(); + PersistentState persistentState = PersistentState.Get(); if (persistentState.CachedSolutions.Count > 0) { return persistentState.CachedSolutions; @@ -307,8 +307,8 @@ internal static Collection DiscoverSolutions(AbsoluteDirectoryPath roo internal static bool IsSolutionNested(AbsoluteFilePath solutionPath) { - var solutionDir = solutionPath.DirectoryPath; - var checkDir = solutionDir; + AbsoluteDirectoryPath solutionDir = solutionPath.DirectoryPath; + AbsoluteDirectoryPath checkDir = solutionDir; do { checkDir = checkDir.Parent; diff --git a/CrossRepoActions/Git.cs b/CrossRepoActions/Git.cs index f0e956f..c8f7dd5 100644 --- a/CrossRepoActions/Git.cs +++ b/CrossRepoActions/Git.cs @@ -12,7 +12,7 @@ internal static class Git { internal static IEnumerable DiscoverRepositories(AbsoluteDirectoryPath root) { - var persistentState = PersistentState.Get(); + PersistentState persistentState = PersistentState.Get(); if (persistentState.CachedRepos.Count > 0) { return persistentState.CachedRepos; @@ -31,7 +31,15 @@ internal static IEnumerable DiscoverRepositories(Absolute internal static IEnumerable Pull(AbsoluteDirectoryPath repo) { - Collection results = []; + using var ps = PowerShell.Create(); + var results = ps + .AddCommand("git") + .AddArgument("-C") + .AddArgument(repo.ToString()) + .AddArgument("pull") + .AddArgument("--all") + .AddArgument("-v") + .InvokeAndReturnOutput(PowershellStreams.All); RunCommand.Execute($"git -C {repo} pull --all -v", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); diff --git a/CrossRepoActions/Verbs/BuildAndTest.cs b/CrossRepoActions/Verbs/BuildAndTest.cs index 63067e9..c1ab6c9 100644 --- a/CrossRepoActions/Verbs/BuildAndTest.cs +++ b/CrossRepoActions/Verbs/BuildAndTest.cs @@ -20,23 +20,23 @@ private enum Status internal override void Run(BuildAndTest options) { - var solutions = Dotnet.DiscoverSolutions(options.Path); - var errorSummary = new Collection(); + Collection solutions = Dotnet.DiscoverSolutions(options.Path); + Collection errorSummary = []; - foreach (var solution in solutions) + foreach (Solution solution in solutions) { string cwd = Directory.GetCurrentDirectory(); Directory.SetCurrentDirectory(solution.Path.DirectoryPath); OutputBuildStatus(solution.Path, Status.InProgress, 0); - var solutionErrors = new Collection(); - var projectStatuses = new Collection(); - var projectErrors = new Collection(); + Collection solutionErrors = []; + Collection projectStatuses = []; + Collection projectErrors = []; - foreach (var project in solution.Projects) + foreach (AbsoluteFilePath project in solution.Projects) { - var results = Dotnet.BuildProject(project); + Collection results = Dotnet.BuildProject(project); solutionErrors.AddMany(results); if (results.Count != 0) { @@ -69,7 +69,7 @@ internal override void Run(BuildAndTest options) continue; } - var testOutput = Dotnet.RunTests(); + Collection testOutput = Dotnet.RunTests(); testOutput = testOutput .Where(l => l.EndsWithOrdinal("]") && (l.Contains("Passed") || l.Contains("Failed"))) .Select(s => diff --git a/CrossRepoActions/Verbs/GitPull.cs b/CrossRepoActions/Verbs/GitPull.cs index c750893..302f9f7 100644 --- a/CrossRepoActions/Verbs/GitPull.cs +++ b/CrossRepoActions/Verbs/GitPull.cs @@ -11,8 +11,8 @@ internal class GitPull : BaseVerb { internal override void Run(GitPull options) { - var errorSummary = new ConcurrentBag(); - var repos = Git.DiscoverRepositories(options.Path); + ConcurrentBag errorSummary = []; + IEnumerable repos = Git.DiscoverRepositories(options.Path); _ = Parallel.ForEach(repos, new() { MaxDegreeOfParallelism = Program.MaxParallelism, diff --git a/CrossRepoActions/Verbs/Menu.cs b/CrossRepoActions/Verbs/Menu.cs index 7f4fba2..abfb69e 100644 --- a/CrossRepoActions/Verbs/Menu.cs +++ b/CrossRepoActions/Verbs/Menu.cs @@ -14,22 +14,21 @@ internal class Menu : BaseVerb { internal override void Run(Menu options) { - var scrollMenu = new ScrollMenu() + ScrollMenu scrollMenu = new() { HorizontalAlignment = HorizontalAlignment.Left, ItemsHorizontalAlignment = HorizontalAlignment.Left, KeepHighlightingOnClose = true, }; - var menuRepeater = new ControlRepeater() + ControlRepeater menuRepeater = new() { Control = scrollMenu, }; - var menuItems = Program.Verbs + LabelMenuItem[] menuItems = [.. Program.Verbs .Where(verb => verb != GetType()) - .Select(CreateMenuItem) - .ToArray(); + .Select(CreateMenuItem)]; scrollMenu.AddItems(menuItems); @@ -41,7 +40,7 @@ internal override void Run(Menu options) private static LabelMenuItem CreateMenuItem(Type verbType) { - var verb = Activator.CreateInstance(verbType) as BaseVerb; + BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb; Debug.Assert(verb != null); return new LabelMenuItem() { diff --git a/CrossRepoActions/Verbs/UpdatePackages.cs b/CrossRepoActions/Verbs/UpdatePackages.cs index 2ba6797..42d3ad2 100644 --- a/CrossRepoActions/Verbs/UpdatePackages.cs +++ b/CrossRepoActions/Verbs/UpdatePackages.cs @@ -16,8 +16,8 @@ internal override void Run(UpdatePackages options) { while (true) { - var errorSummary = new ConcurrentBag(); - var solutions = Dotnet.DiscoverSolutions(options.Path); + ConcurrentBag errorSummary = []; + Collection solutions = Dotnet.DiscoverSolutions(options.Path); _ = Parallel.ForEach(solutions, new() { @@ -27,7 +27,7 @@ internal override void Run(UpdatePackages options) { try { - foreach (var project in solution.Projects) + foreach (StrongPaths.AbsoluteFilePath project in solution.Projects) { var solutionDir = solution.Path.DirectoryPath; bool isProjectFileModified = Git.Status(solutionDir, project).Any(); @@ -40,7 +40,7 @@ internal override void Run(UpdatePackages options) var errorLines = new Collection(); foreach (var package in outdatedPackages) { - var packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase) && !s.Contains("imported file", StringComparison.InvariantCultureIgnoreCase)); + IEnumerable packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase) && !s.Contains("imported file", StringComparison.InvariantCultureIgnoreCase)); if (packageErrors.Any()) { errorLines.AddMany(packageErrors); diff --git a/scripts/PSBuild.psm1 b/scripts/PSBuild.psm1 new file mode 100644 index 0000000..05d1702 --- /dev/null +++ b/scripts/PSBuild.psm1 @@ -0,0 +1,2425 @@ +# PSBuild Module for .NET CI/CD +# Author: ktsu.dev +# License: MIT +# +# A comprehensive PowerShell module for automating the build, test, package, +# and release process for .NET applications using Git-based versioning. +# See README.md for detailed documentation and usage examples. + +# Set Strict Mode +Set-StrictMode -Version Latest + +#region Environment and Configuration + +function Initialize-BuildEnvironment { + <# + .SYNOPSIS + Initializes the build environment with standard settings. + .DESCRIPTION + Sets up environment variables for .NET SDK and initializes other required build settings. + #> + [CmdletBinding()] + param() + + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1' + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = 'true' + + Write-Information "Build environment initialized" -Tags "Initialize-BuildEnvironment" +} + +function Get-BuildConfiguration { + <# + .SYNOPSIS + Gets the build configuration based on Git status and environment. + .DESCRIPTION + Determines if this is a release build, checks Git status, and sets up build paths. + Returns a configuration object containing all necessary build settings and paths. + .PARAMETER ServerUrl + The server URL to use for the build. + .PARAMETER GitRef + The Git reference (branch/tag) being built. + .PARAMETER GitSha + The Git commit SHA being built. + .PARAMETER GitHubOwner + The GitHub owner of the repository. + .PARAMETER GitHubRepo + The GitHub repository name. + .PARAMETER GithubToken + The GitHub token for API operations. + .PARAMETER NuGetApiKey + The NuGet API key for package publishing. + .PARAMETER WorkspacePath + The path to the workspace/repository root. + .PARAMETER ExpectedOwner + The expected owner/organization of the official repository. + .PARAMETER ChangelogFile + The path to the changelog file. + .PARAMETER LatestChangelogFile + The path to the file containing only the latest version's changelog. Defaults to "LATEST_CHANGELOG.md". + .PARAMETER AssetPatterns + Array of glob patterns for release assets. + .OUTPUTS + PSCustomObject containing build configuration data with Success, Error, and Data properties. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [Parameter(Mandatory=$true)] + [string]$ServerUrl, + [Parameter(Mandatory=$true)] + [string]$GitRef, + [Parameter(Mandatory=$true)] + [string]$GitSha, + [Parameter(Mandatory=$true)] + [string]$GitHubOwner, + [Parameter(Mandatory=$true)] + [string]$GitHubRepo, + [Parameter(Mandatory=$true)] + [string]$GithubToken, + [Parameter(Mandatory=$true)] + [string]$NuGetApiKey, + [Parameter(Mandatory=$true)] + [string]$WorkspacePath, + [Parameter(Mandatory=$true)] + [string]$ExpectedOwner, + [Parameter(Mandatory=$true)] + [string]$ChangelogFile, + [Parameter(Mandatory=$false)] + [string]$LatestChangelogFile = "LATEST_CHANGELOG.md", + [Parameter(Mandatory=$true)] + [string[]]$AssetPatterns + ) + + # Determine if this is an official repo (verify owner and ensure it's not a fork) + $IS_OFFICIAL = $false + if ($GithubToken) { + try { + $env:GH_TOKEN = $GithubToken + $repoInfo = "gh repo view --json owner,nameWithOwner,isFork 2>`$null" | Invoke-ExpressionWithLogging -Tags "Get-BuildConfiguration" | ConvertFrom-Json + if ($repoInfo) { + # Consider it official only if it's not a fork AND belongs to the expected owner + $IS_OFFICIAL = (-not $repoInfo.isFork) -and ($repoInfo.owner.login -eq $ExpectedOwner) + Write-Information "Repository: $($repoInfo.nameWithOwner), Is Fork: $($repoInfo.isFork), Owner: $($repoInfo.owner.login)" -Tags "Get-BuildConfiguration" + } else { + Write-Information "Could not retrieve repository information. Assuming unofficial build." -Tags "Get-BuildConfiguration" + } + } + catch { + Write-Information "Failed to check repository status: $_. Assuming unofficial build." -Tags "Get-BuildConfiguration" + } + } + + Write-Information "Is Official: $IS_OFFICIAL" -Tags "Get-BuildConfiguration" + + # Determine if this is main branch and not tagged + $IS_MAIN = $GitRef -eq "refs/heads/main" + $IS_TAGGED = "(git show-ref --tags -d | Out-String).Contains(`"$GitSha`")" | Invoke-ExpressionWithLogging -Tags "Get-BuildConfiguration" + $SHOULD_RELEASE = ($IS_MAIN -AND -NOT $IS_TAGGED -AND $IS_OFFICIAL) + + # Check for .csx files (dotnet-script) + $csx = @(Get-ChildItem -Path $WorkspacePath -Recurse -Filter *.csx -ErrorAction SilentlyContinue) + $USE_DOTNET_SCRIPT = $csx.Count -gt 0 + + # Setup paths + $OUTPUT_PATH = Join-Path $WorkspacePath 'output' + $STAGING_PATH = Join-Path $WorkspacePath 'staging' + + # Setup artifact patterns + $PACKAGE_PATTERN = Join-Path $STAGING_PATH "*.nupkg" + $SYMBOLS_PATTERN = Join-Path $STAGING_PATH "*.snupkg" + $APPLICATION_PATTERN = Join-Path $STAGING_PATH "*.zip" + + # Set build arguments + $BUILD_ARGS = "" + if ($USE_DOTNET_SCRIPT) { + $BUILD_ARGS = "-maxCpuCount:1" + } + + # Create configuration object with standard format + $config = [PSCustomObject]@{ + Success = $true + Error = "" + Data = @{ + IsOfficial = $IS_OFFICIAL + IsMain = $IS_MAIN + IsTagged = $IS_TAGGED + ShouldRelease = $SHOULD_RELEASE + UseDotnetScript = $USE_DOTNET_SCRIPT + OutputPath = $OUTPUT_PATH + StagingPath = $STAGING_PATH + PackagePattern = $PACKAGE_PATTERN + SymbolsPattern = $SYMBOLS_PATTERN + ApplicationPattern = $APPLICATION_PATTERN + BuildArgs = $BUILD_ARGS + WorkspacePath = $WorkspacePath + DotnetVersion = $script:DOTNET_VERSION + ServerUrl = $ServerUrl + GitRef = $GitRef + GitSha = $GitSha + GitHubOwner = $GitHubOwner + GitHubRepo = $GitHubRepo + GithubToken = $GithubToken + NuGetApiKey = $NuGetApiKey + ExpectedOwner = $ExpectedOwner + Version = "1.0.0-pre.0" + ReleaseHash = $GitSha + ChangelogFile = $ChangelogFile + LatestChangelogFile = $LatestChangelogFile + AssetPatterns = $AssetPatterns + } + } + + return $config +} + +#endregion + +#region Version Management + +function Get-GitTags { + <# + .SYNOPSIS + Gets sorted git tags from the repository. + .DESCRIPTION + Retrieves a list of git tags sorted by version in descending order. + Returns a default tag if no tags exist. + #> + [CmdletBinding()] + [OutputType([string[]])] + param () + + # Configure git versionsort to correctly handle prereleases + $suffixes = @('-alpha', '-beta', '-rc', '-pre') + foreach ($suffix in $suffixes) { + "git config versionsort.suffix `"$suffix`"" | Invoke-ExpressionWithLogging -Tags "Get-GitTags" | Write-InformationStream -Tags "Get-GitTags" + } + + Write-Information "Getting sorted tags..." -Tags "Get-GitTags" + # Get tags + $output = "git tag --list --sort=-v:refname" | Invoke-ExpressionWithLogging -Tags "Get-GitTags" + + # Ensure we always return an array + if ($null -eq $output) { + Write-Information "No tags found, returning empty array" -Tags "Get-GitTags" + return @() + } + + # Convert to array if it's not already + if ($output -isnot [array]) { + if ([string]::IsNullOrWhiteSpace($output)) { + Write-Information "No tags found, returning empty array" -Tags "Get-GitTags" + return @() + } + $output = @($output) + } + + if ($output.Count -eq 0) { + Write-Information "No tags found, returning empty array" -Tags "Get-GitTags" + return @() + } + + Write-Information "Found $($output.Count) tags" -Tags "Get-GitTags" + return $output +} + +function Get-VersionType { + <# + .SYNOPSIS + Determines the type of version bump needed based on commit history and public API changes + .DESCRIPTION + Analyzes commit messages and code changes to determine whether the next version should be: + - Major (1.0.0 → 2.0.0): Breaking changes, indicated by [major] tags in commits + - Minor (1.0.0 → 1.1.0): Non-breaking public API changes (additions, modifications, removals) + - Patch (1.0.0 → 1.0.1): Bug fixes and changes that don't modify the public API + - Prerelease (1.0.0 → 1.0.1-pre.1): Small changes or no significant changes + - Skip: Only [skip ci] commits or no significant changes requiring a version bump + + Version bump determination follows these rules in order: + 1. Explicit tags in commit messages: [major], [minor], [patch], [pre] + 2. Public API changes detection via regex patterns (triggers minor bump) + 3. Code changes that don't modify public API (triggers patch bump) + 4. Default to prerelease bump for minimal changes + 5. If only [skip ci] commits are found, suggest skipping the release + .PARAMETER Range + The git commit range to analyze (e.g., "v1.0.0...HEAD" or a specific commit range) + .OUTPUTS + Returns a PSCustomObject with 'Type' and 'Reason' properties explaining the version increment decision. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [Parameter(Mandatory=$true)] + [string]$Range + ) + + # Initialize to the most conservative version bump + $versionType = "prerelease" + $reason = "No significant changes detected" + + # Bot and PR patterns to exclude + $EXCLUDE_BOTS = '^(?!.*(\[bot\]|github|ProjectDirector|SyncFileContents)).*$' + $EXCLUDE_PRS = '^.*(Merge pull request|Merge branch ''main''|Updated packages in|Update.*package version).*$' + + # First check for explicit version markers in commit messages + $messages = "git log --format=format:%s `"$Range`"" | Invoke-ExpressionWithLogging -Tags "Get-VersionType" + + # Ensure messages is always an array + if ($null -eq $messages) { + $messages = @() + } elseif ($messages -isnot [array]) { + $messages = @($messages) + } + + # Check if we have any commits at all + if (@($messages).Count -eq 0) { + return [PSCustomObject]@{ + Type = "skip" + Reason = "No commits found in the specified range" + } + } + + # Check if all commits are skip ci commits + $skipCiPattern = '\[skip ci\]|\[ci skip\]' + $skipCiCommits = $messages | Where-Object { $_ -match $skipCiPattern } + + if (@($skipCiCommits).Count -eq @($messages).Count -and @($messages).Count -gt 0) { + return [PSCustomObject]@{ + Type = "skip" + Reason = "All commits contain [skip ci] tag, skipping release" + } + } + + foreach ($message in $messages) { + if ($message.Contains('[major]')) { + $versionType = 'major' + $reason = "Explicit [major] tag found in commit message: $message" + # Return immediately for major version bumps + return [PSCustomObject]@{ + Type = $versionType + Reason = $reason + } + } elseif ($message.Contains('[minor]') -and $versionType -ne 'major') { + $versionType = 'minor' + $reason = "Explicit [minor] tag found in commit message: $message" + } elseif ($message.Contains('[patch]') -and $versionType -notin @('major', 'minor')) { + $versionType = 'patch' + $reason = "Explicit [patch] tag found in commit message: $message" + } elseif ($message.Contains('[pre]') -and $versionType -eq 'prerelease') { + # Keep as prerelease, but update reason + $reason = "Explicit [pre] tag found in commit message: $message" + } + } + + # If no explicit version markers, check for code changes + if ($versionType -eq "prerelease") { + # Check for any commits that would warrant at least a patch version + $patchCommits = "git log -n 1 --topo-order --perl-regexp --regexp-ignore-case --format=format:%H --committer=`"$EXCLUDE_BOTS`" --author=`"$EXCLUDE_BOTS`" --grep=`"$EXCLUDE_PRS`" --invert-grep `"$Range`"" | Invoke-ExpressionWithLogging -Tags "Get-VersionType" + + if ($patchCommits) { + $versionType = "patch" + $reason = "Found changes warranting at least a patch version" + + # Check for public API changes that would warrant a minor version + + # First, check if we can detect public API changes via git diff + $apiChangePatterns = @( + # C# public API patterns + '^\+\s*(public|protected)\s+(class|interface|enum|struct|record)\s+\w+', # Added public types + '^\+\s*(public|protected)\s+\w+\s+\w+\s*\(', # Added public methods + '^\+\s*(public|protected)\s+\w+(\s+\w+)*\s*{', # Added public properties + '^\-\s*(public|protected)\s+(class|interface|enum|struct|record)\s+\w+', # Removed public types + '^\-\s*(public|protected)\s+\w+\s+\w+\s*\(', # Removed public methods + '^\-\s*(public|protected)\s+\w+(\s+\w+)*\s*{', # Removed public properties + '^\+\s*public\s+const\s', # Added public constants + '^\-\s*public\s+const\s' # Removed public constants + ) + + # Combine patterns for git diff + $apiChangePattern = "(" + ($apiChangePatterns -join ")|(") + ")" + + # Search for API changes + $apiDiffCmd = "git diff `"$Range`" -- `"*.cs`" | Select-String -Pattern `"$apiChangePattern`" -SimpleMatch" + $apiChanges = Invoke-Expression $apiDiffCmd + + if ($apiChanges) { + $versionType = "minor" + $reason = "Public API changes detected (additions, removals, or modifications)" + return [PSCustomObject]@{ + Type = $versionType + Reason = $reason + } + } + } + } + + return [PSCustomObject]@{ + Type = $versionType + Reason = $reason + } +} + +function Get-VersionInfoFromGit { + <# + .SYNOPSIS + Gets comprehensive version information based on Git tags and commit analysis. + .DESCRIPTION + Finds the most recent version tag, analyzes commit history, and determines the next version + following semantic versioning principles. Returns a rich object with all version components. + .PARAMETER CommitHash + The Git commit hash being built. + .PARAMETER InitialVersion + The version to use if no tags exist. Defaults to "1.0.0". + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [Parameter(Mandatory=$true)] + [string]$CommitHash, + [string]$InitialVersion = "1.0.0" + ) + + Write-StepHeader "Analyzing Version Information" -Tags "Get-VersionInfoFromGit" + Write-Information "Analyzing repository for version information..." -Tags "Get-VersionInfoFromGit" + Write-Information "Commit hash: $CommitHash" -Tags "Get-VersionInfoFromGit" + + # Get all tags + $tags = Get-GitTags + + # Ensure tags is always an array + if ($null -eq $tags) { + $tags = @() + } elseif ($tags -isnot [array]) { + $tags = @($tags) + } + + Write-Information "Found $(@($tags).Count) tag(s)" -Tags "Get-VersionInfoFromGit" + + # Get the last tag and its commit + $usingFallbackTag = $false + $lastTag = "" + + if (@($tags).Count -eq 0) { + $lastTag = "v$InitialVersion-pre.0" + $usingFallbackTag = $true + Write-Information "No tags found. Using fallback: $lastTag" -Tags "Get-VersionInfoFromGit" + } else { + $lastTag = $tags[0] + Write-Information "Using last tag: $lastTag" -Tags "Get-VersionInfoFromGit" + } + + # Extract the version without 'v' prefix + $lastVersion = $lastTag -replace 'v', '' + Write-Information "Last version: $lastVersion" -Tags "Get-VersionInfoFromGit" + + # Parse previous version + $wasPrerelease = $lastVersion.Contains('-') + $cleanVersion = $lastVersion -replace '-alpha.*$', '' -replace '-beta.*$', '' -replace '-rc.*$', '' -replace '-pre.*$', '' + + $parts = $cleanVersion -split '\.' + $lastMajor = [int]$parts[0] + $lastMinor = [int]$parts[1] + $lastPatch = [int]$parts[2] + $lastPrereleaseNum = 0 + + # Extract prerelease number if applicable + if ($wasPrerelease -and $lastVersion -match '-(?:pre|alpha|beta|rc)\.(\d+)') { + $lastPrereleaseNum = [int]$Matches[1] + } + + # Determine version increment type based on commit range + Write-Information "$($script:lineEnding)Getting commits to analyze..." -Tags "Get-VersionInfoFromGit" + + # Get the first commit in repo for fallback + $firstCommit = "git rev-list HEAD" | Invoke-ExpressionWithLogging -Tags "Get-VersionInfoFromGit" + if ($firstCommit -is [array] -and @($firstCommit).Count -gt 0) { + $firstCommit = $firstCommit[-1] + } + Write-Information "First commit: $firstCommit" -Tags "Get-VersionInfoFromGit" + + # Find the last tag's commit + $lastTagCommit = "" + if ($usingFallbackTag) { + $lastTagCommit = $firstCommit + Write-Information "Using first commit as starting point: $firstCommit" -Tags "Get-VersionInfoFromGit" + } else { + $lastTagCommit = "git rev-list -n 1 $lastTag" | Invoke-ExpressionWithLogging -Tags "Get-VersionInfoFromGit" + Write-Information "Last tag commit: $lastTagCommit" -Tags "Get-VersionInfoFromGit" + } + + # Define the commit range to analyze + $commitRange = "$lastTagCommit..$CommitHash" + Write-Information "Analyzing commit range: $commitRange" -Tags "Get-VersionInfoFromGit" + + # Get the increment type + $incrementInfo = Get-VersionType -Range $commitRange + $incrementType = $incrementInfo.Type + $incrementReason = $incrementInfo.Reason + + # If type is "skip", return the current version without bumping + if ($incrementType -eq "skip") { + Write-Information "Version increment type: $incrementType" -Tags "Get-VersionInfoFromGit" + Write-Information "Reason: $incrementReason" -Tags "Get-VersionInfoFromGit" + + # Use the same version, don't increment + $newVersion = $lastVersion + + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $newVersion + Major = $lastMajor + Minor = $lastMinor + Patch = $lastPatch + IsPrerelease = $wasPrerelease + PrereleaseNumber = $lastPrereleaseNum + PrereleaseLabel = if ($wasPrerelease) { ($lastVersion -split '-')[1].Split('.')[0] } else { "pre" } + LastTag = $lastTag + LastVersion = $lastVersion + LastVersionMajor = $lastMajor + LastVersionMinor = $lastMinor + LastVersionPatch = $lastPatch + WasPrerelease = $wasPrerelease + LastVersionPrereleaseNumber = $lastPrereleaseNum + VersionIncrement = $incrementType + IncrementReason = $incrementReason + FirstCommit = $firstCommit + LastCommit = $CommitHash + LastTagCommit = $lastTagCommit + UsingFallbackTag = $usingFallbackTag + CommitRange = $commitRange + } + } + } + + # Initialize new version with current values + $newMajor = $lastMajor + $newMinor = $lastMinor + $newPatch = $lastPatch + $newPrereleaseNum = 0 + $isPrerelease = $false + $prereleaseLabel = "pre" + + Write-Information "$($script:lineEnding)Calculating new version..." -Tags "Get-VersionInfoFromGit" + + # Calculate new version based on increment type + switch ($incrementType) { + 'major' { + $newMajor = $lastMajor + 1 + $newMinor = 0 + $newPatch = 0 + Write-Information "Incrementing major version: $lastMajor.$lastMinor.$lastPatch -> $newMajor.0.0" -Tags "Get-VersionInfoFromGit" + } + 'minor' { + $newMinor = $lastMinor + 1 + $newPatch = 0 + Write-Information "Incrementing minor version: $lastMajor.$lastMinor.$lastPatch -> $lastMajor.$newMinor.0" -Tags "Get-VersionInfoFromGit" + } + 'patch' { + if (-not $wasPrerelease) { + $newPatch = $lastPatch + 1 + Write-Information "Incrementing patch version: $lastMajor.$lastMinor.$lastPatch -> $lastMajor.$lastMinor.$newPatch" -Tags "Get-VersionInfoFromGit" + } else { + Write-Information "Converting prerelease to stable version: $lastVersion -> $lastMajor.$lastMinor.$lastPatch" -Tags "Get-VersionInfoFromGit" + } + } + 'prerelease' { + if ($wasPrerelease) { + # Bump prerelease number + $newPrereleaseNum = $lastPrereleaseNum + 1 + $isPrerelease = $true + Write-Information "Incrementing prerelease: $lastVersion -> $lastMajor.$lastMinor.$lastPatch-$prereleaseLabel.$newPrereleaseNum" -Tags "Get-VersionInfoFromGit" + } else { + # Start new prerelease series + $newPatch = $lastPatch + 1 + $newPrereleaseNum = 1 + $isPrerelease = $true + Write-Information "Starting new prerelease: $lastVersion -> $lastMajor.$lastMinor.$newPatch-$prereleaseLabel.1" -Tags "Get-VersionInfoFromGit" + } + } + } + + # Build version string + $newVersion = "$newMajor.$newMinor.$newPatch" + if ($isPrerelease) { + $newVersion += "-$prereleaseLabel.$newPrereleaseNum" + } + + Write-Information "$($script:lineEnding)Version decision:" -Tags "Get-VersionInfoFromGit" + Write-Information "Previous version: $lastVersion" -Tags "Get-VersionInfoFromGit" + Write-Information "New version: $newVersion" -Tags "Get-VersionInfoFromGit" + Write-Information "Reason: $incrementReason" -Tags "Get-VersionInfoFromGit" + + try { + # Return comprehensive object with standard format + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $newVersion + Major = $newMajor + Minor = $newMinor + Patch = $newPatch + IsPrerelease = $isPrerelease + PrereleaseNumber = $newPrereleaseNum + PrereleaseLabel = $prereleaseLabel + LastTag = $lastTag + LastVersion = $lastVersion + LastVersionMajor = $lastMajor + LastVersionMinor = $lastMinor + LastVersionPatch = $lastPatch + WasPrerelease = $wasPrerelease + LastVersionPrereleaseNumber = $lastPrereleaseNum + VersionIncrement = $incrementType + IncrementReason = $incrementReason + FirstCommit = $firstCommit + LastCommit = $CommitHash + LastTagCommit = $lastTagCommit + UsingFallbackTag = $usingFallbackTag + CommitRange = $commitRange + } + } + } + catch { + return [PSCustomObject]@{ + Success = $false + Error = $_.ToString() + Data = [PSCustomObject]@{ + ErrorDetails = $_.Exception.Message + StackTrace = $_.ScriptStackTrace + } + StackTrace = $_.ScriptStackTrace + } + } +} + +function New-Version { + <# + .SYNOPSIS + Creates a new version file and sets environment variables. + .DESCRIPTION + Generates a new version number based on git history, writes it to version files, + and optionally sets GitHub environment variables for use in Actions. + .PARAMETER CommitHash + The Git commit hash being built. + .PARAMETER OutputPath + Optional path to write the version file to. Defaults to workspace root. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory=$true)] + [string]$CommitHash, + [string]$OutputPath = "" + ) + + # Get complete version information object + $versionInfo = Get-VersionInfoFromGit -CommitHash $CommitHash + + # Write version file with correct line ending + $filePath = if ($OutputPath) { Join-Path $OutputPath "VERSION.md" } else { "VERSION.md" } + $version = $versionInfo.Data.Version.Trim() + [System.IO.File]::WriteAllText($filePath, $version + $script:lineEnding, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-Version" + + Write-Information "Previous version: $($versionInfo.Data.LastVersion), New version: $($versionInfo.Data.Version)" -Tags "New-Version" + + return $versionInfo.Data.Version +} + +#endregion + +#region License Management + +function New-License { + <# + .SYNOPSIS + Creates a license file from template. + .DESCRIPTION + Generates a LICENSE.md file using the template and repository information. + .PARAMETER ServerUrl + The GitHub server URL. + .PARAMETER Owner + The repository owner/organization. + .PARAMETER Repository + The repository name. + .PARAMETER OutputPath + Optional path to write the license file to. Defaults to workspace root. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ServerUrl, + [Parameter(Mandatory=$true)] + [string]$Owner, + [Parameter(Mandatory=$true)] + [string]$Repository, + [string]$OutputPath = "" + ) + + if (-not (Test-Path $script:LICENSE_TEMPLATE)) { + throw "License template not found at: $script:LICENSE_TEMPLATE" + } + + $year = (Get-Date).Year + $content = Get-Content $script:LICENSE_TEMPLATE -Raw + + # Project URL + $projectUrl = "$ServerUrl/$Repository" + $content = $content.Replace('{PROJECT_URL}', $projectUrl) + + # Copyright line + $copyright = "Copyright (c) 2023-$year $Owner" + $content = $content.Replace('{COPYRIGHT}', $copyright) + + # Normalize line endings + $content = $content.ReplaceLineEndings($script:lineEnding) + + $copyrightFilePath = if ($OutputPath) { Join-Path $OutputPath "COPYRIGHT.md" } else { "COPYRIGHT.md" } + [System.IO.File]::WriteAllText($copyrightFilePath, $copyright + $script:lineEnding, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-License" + + $filePath = if ($OutputPath) { Join-Path $OutputPath "LICENSE.md" } else { "LICENSE.md" } + [System.IO.File]::WriteAllText($filePath, $content, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-License" + + Write-Information "License file created at: $filePath" -Tags "New-License" +} + +#endregion + +#region Changelog Management + +function ConvertTo-FourComponentVersion { + <# + .SYNOPSIS + Converts a version tag to a four-component version for comparison. + .DESCRIPTION + Standardizes version tags to a four-component version (major.minor.patch.prerelease) for easier comparison. + .PARAMETER VersionTag + The version tag to convert. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory=$true)] + [string]$VersionTag + ) + + $version = $VersionTag -replace 'v', '' + $version = $version -replace '-alpha', '' -replace '-beta', '' -replace '-rc', '' -replace '-pre', '' + $versionComponents = $version -split '\.' + $versionMajor = [int]$versionComponents[0] + $versionMinor = [int]$versionComponents[1] + $versionPatch = [int]$versionComponents[2] + $versionPrerelease = 0 + + if (@($versionComponents).Count -gt 3) { + $versionPrerelease = [int]$versionComponents[3] + } + + return "$versionMajor.$versionMinor.$versionPatch.$versionPrerelease" +} + +function Get-VersionNotes { + <# + .SYNOPSIS + Generates changelog notes for a specific version range. + .DESCRIPTION + Creates formatted changelog entries for commits between two version tags. + .PARAMETER Tags + All available tags in the repository. + .PARAMETER FromTag + The starting tag of the range. + .PARAMETER ToTag + The ending tag of the range. + .PARAMETER ToSha + Optional specific commit SHA to use as the range end. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory=$true)] + [AllowEmptyCollection()] + [string[]]$Tags, + [Parameter(Mandatory=$true)] + [string]$FromTag, + [Parameter(Mandatory=$true)] + [string]$ToTag, + [Parameter()] + [string]$ToSha = "" + ) + + # Define common patterns used for filtering commits + $EXCLUDE_BOTS = '^(?!.*(\[bot\]|github|ProjectDirector|SyncFileContents)).*$' + $EXCLUDE_PRS = '^.*(Merge pull request|Merge branch ''main''|Updated packages in|Update.*package version).*$' + + # Convert tags to comparable versions + $toVersion = ConvertTo-FourComponentVersion -VersionTag $ToTag + $fromVersion = ConvertTo-FourComponentVersion -VersionTag $FromTag + + # Parse components for comparison + $toVersionComponents = $toVersion -split '\.' + $toVersionMajor = [int]$toVersionComponents[0] + $toVersionMinor = [int]$toVersionComponents[1] + $toVersionPatch = [int]$toVersionComponents[2] + $toVersionPrerelease = [int]$toVersionComponents[3] + + $fromVersionComponents = $fromVersion -split '\.' + $fromVersionMajor = [int]$fromVersionComponents[0] + $fromVersionMinor = [int]$fromVersionComponents[1] + $fromVersionPatch = [int]$fromVersionComponents[2] + $fromVersionPrerelease = [int]$fromVersionComponents[3] + + # Calculate previous version numbers for finding the correct tag + $fromMajorVersionNumber = $toVersionMajor - 1 + $fromMinorVersionNumber = $toVersionMinor - 1 + $fromPatchVersionNumber = $toVersionPatch - 1 + $fromPrereleaseVersionNumber = $toVersionPrerelease - 1 + + # Determine version type and search tag + $searchTag = $FromTag + $versionType = "unknown" + + if ($toVersionPrerelease -ne 0) { + $versionType = "prerelease" + $searchTag = "$toVersionMajor.$toVersionMinor.$toVersionPatch.$fromPrereleaseVersionNumber" + } + else { + if ($toVersionPatch -gt $fromVersionPatch) { + $versionType = "patch" + $searchTag = "$toVersionMajor.$toVersionMinor.$fromPatchVersionNumber.0" + } + if ($toVersionMinor -gt $fromVersionMinor) { + $versionType = "minor" + $searchTag = "$toVersionMajor.$fromMinorVersionNumber.0.0" + } + if ($toVersionMajor -gt $fromVersionMajor) { + $versionType = "major" + $searchTag = "$fromMajorVersionNumber.0.0.0" + } + } + + # Handle case where version is same but prerelease was dropped + if ($toVersionMajor -eq $fromVersionMajor -and + $toVersionMinor -eq $fromVersionMinor -and + $toVersionPatch -eq $fromVersionPatch -and + $toVersionPrerelease -eq 0 -and + $fromVersionPrerelease -ne 0) { + $versionType = "patch" + $searchTag = "$toVersionMajor.$toVersionMinor.$fromPatchVersionNumber.0" + } + + if ($searchTag.Contains("-")) { + $searchTag = $FromTag + } + + $searchVersion = ConvertTo-FourComponentVersion -VersionTag $searchTag + + if ($FromTag -ne "v0.0.0") { + $foundSearchTag = $false + $Tags | ForEach-Object { + if (-not $foundSearchTag) { + $otherTag = $_ + $otherVersion = ConvertTo-FourComponentVersion -VersionTag $otherTag + if ($searchVersion -eq $otherVersion) { + $foundSearchTag = $true + $searchTag = $otherTag + } + } + } + + if (-not $foundSearchTag) { + $searchTag = $FromTag + } + } + + $rangeFrom = $searchTag + if ($rangeFrom -eq "v0.0.0" -or $rangeFrom -eq "0.0.0.0" -or $rangeFrom -eq "1.0.0.0") { + $rangeFrom = "" + } + + $rangeTo = $ToSha + if ($rangeTo -eq "") { + $rangeTo = $ToTag + } + + # Determine proper commit range + $isNewestVersion = $false + if ($ToSha -ne "") { + # If ToSha is provided, this is likely the newest version being generated + $isNewestVersion = $true + } + + # Get the actual commit SHA for the from tag if it exists + $range = "" + $fromSha = "" + $gitSuccess = $true + + if ($rangeFrom -ne "") { + try { + # Try to get the SHA for the from tag, but don't error if it doesn't exist + $fromSha = "git rev-list -n 1 $rangeFrom 2>`$null" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + if ($LASTEXITCODE -ne 0) { + Write-Information "Warning: Could not find SHA for tag $rangeFrom. Using fallback range." -Tags "Get-VersionNotes" + $gitSuccess = $false + $fromSha = "" + } + + # For the newest version with SHA provided (not yet tagged): + if ($isNewestVersion -and $ToSha -ne "" -and $gitSuccess) { + $range = "$fromSha..$ToSha" + } elseif ($gitSuccess) { + # For already tagged versions, get the SHA for the to tag + $toShaResolved = "git rev-list -n 1 $rangeTo 2>`$null" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + if ($LASTEXITCODE -ne 0) { + Write-Information "Warning: Could not find SHA for tag $rangeTo. Using fallback range." -Tags "Get-VersionNotes" + $gitSuccess = $false + } + else { + $range = "$fromSha..$toShaResolved" + } + } + } + catch { + Write-Information "Error getting commit SHAs: $_" -Tags "Get-VersionNotes" + $gitSuccess = $false + } + } + + # Handle case with no FROM tag (first version) or failed git commands + if ($rangeFrom -eq "" -or -not $gitSuccess) { + if ($ToSha -ne "") { + $range = $ToSha + } else { + try { + $toShaResolved = "git rev-list -n 1 $rangeTo 2>`$null" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + if ($LASTEXITCODE -eq 0) { + $range = $toShaResolved + } else { + # If we can't resolve either tag, use HEAD as fallback + $range = "HEAD" + } + } + catch { + Write-Information "Error resolving tag SHA: $_. Using HEAD instead." -Tags "Get-VersionNotes" + $range = "HEAD" + } + } + } + + # Debug output + Write-Information "Processing range: $range (From: $rangeFrom, To: $rangeTo)" -Tags "Get-VersionNotes" + + # For repositories with no valid tags or no commits between tags, handle gracefully + if ([string]::IsNullOrWhiteSpace($range) -or $range -eq ".." -or $range -match '^\s*$') { + Write-Information "No valid commit range found. Creating a placeholder entry." -Tags "Get-VersionNotes" + $versionType = "initial" # Mark as initial release + $versionChangelog = "## $ToTag (initial release)$script:lineEnding$script:lineEnding" + $versionChangelog += "Initial version.$script:lineEnding$script:lineEnding" + return ($versionChangelog.Trim() + $script:lineEnding) + } + + # Try with progressively more relaxed filtering to ensure we show commits + $rawCommits = @() + + try { + # Get full commit info with hash to ensure uniqueness + $format = '%h|%s|%aN' + + # First try with standard filters + $rawCommitsResult = "git log --pretty=format:`"$format`" --perl-regexp --regexp-ignore-case --grep=`"$EXCLUDE_PRS`" --invert-grep --committer=`"$EXCLUDE_BOTS`" --author=`"$EXCLUDE_BOTS`" `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + + # Safely convert to array and handle any errors + $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult + + # Additional safety check - ensure we have a valid array with Count property + if ($null -eq $rawCommits) { + Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" + $rawCommits = @() + } + + # Use @() subexpression to safely get count + $rawCommitsCount = @($rawCommits).Count + + # If no commits found, try with just PR exclusion but no author filtering + if ($rawCommitsCount -eq 0) { + Write-Information "No commits found with standard filters, trying with relaxed author/committer filters..." -Tags "Get-VersionNotes" + $rawCommitsResult = "git log --pretty=format:`"$format`" --perl-regexp --regexp-ignore-case --grep=`"$EXCLUDE_PRS`" --invert-grep `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + + # Safely convert to array and handle any errors + $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult + + # Additional safety check + if ($null -eq $rawCommits) { + Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" + $rawCommits = @() + } + } + + # Use @() subexpression to safely get count + $rawCommitsCount = @($rawCommits).Count + + # If still no commits, try with no filtering at all - show everything in the range + if ($rawCommitsCount -eq 0) { + Write-Information "Still no commits found, trying with no filters..." -Tags "Get-VersionNotes" + $rawCommitsResult = "git log --pretty=format:`"$format`" `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + + # Safely convert to array and handle any errors + $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult + + # Additional safety check + if ($null -eq $rawCommits) { + Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" + $rawCommits = @() + } + + # Use @() subexpression to safely get count + $rawCommitsCount = @($rawCommits).Count + + # If it's a prerelease version, include also version update commits + if ($versionType -eq "prerelease" -and $rawCommitsCount -eq 0) { + Write-Information "Looking for version update commits for prerelease..." -Tags "Get-VersionNotes" + $rawCommitsResult = "git log --pretty=format:`"$format`" --grep=`"Update VERSION to`" `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue + + # Safely convert to array and handle any errors + $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult + + # Additional safety check + if ($null -eq $rawCommits) { + Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" + $rawCommits = @() + } + } + } + } + catch { + Write-Information "Error during git log operations: $_" -Tags "Get-VersionNotes" + $rawCommits = @() + } + + # Process raw commits into structured format + $structuredCommits = @() + foreach ($commit in $rawCommits) { + $parts = $commit -split '\|' + # Use @() subexpression to safely get count + if (@($parts).Count -ge 3) { + $structuredCommits += [PSCustomObject]@{ + Hash = $parts[0] + Subject = $parts[1] + Author = $parts[2] + FormattedEntry = "$($parts[1]) ([@$($parts[2])](https://github.com/$($parts[2])))" + } + } + } + + # Get unique commits based on hash (ensures unique commits) + $uniqueCommits = ConvertTo-ArraySafe -InputObject ($structuredCommits | Sort-Object -Property Hash -Unique | ForEach-Object { $_.FormattedEntry }) + + # Use @() subexpression to safely get count + $uniqueCommitsCount = @($uniqueCommits).Count + Write-Information "Found $uniqueCommitsCount commits for $ToTag" -Tags "Get-VersionNotes" + + # Format changelog entry + $versionChangelog = "" + if ($uniqueCommitsCount -gt 0) { + $versionChangelog = "## $ToTag" + if ($versionType -ne "unknown") { + $versionChangelog += " ($versionType)" + } + $versionChangelog += "$script:lineEnding$script:lineEnding" + + if ($rangeFrom -ne "") { + $versionChangelog += "Changes since ${rangeFrom}:$script:lineEnding$script:lineEnding" + } + + # Only filter out version updates for non-prerelease versions + if ($versionType -ne "prerelease") { + $filteredCommits = $uniqueCommits | Where-Object { -not $_.Contains("Update VERSION to") -and -not $_.Contains("[skip ci]") } + } else { + $filteredCommits = $uniqueCommits | Where-Object { -not $_.Contains("[skip ci]") } + } + + foreach ($commit in $filteredCommits) { + $versionChangelog += "- $commit$script:lineEnding" + } + $versionChangelog += "$script:lineEnding" + } elseif ($versionType -eq "prerelease") { + # For prerelease versions with no detected commits, include a placeholder entry + $versionChangelog = "## $ToTag (prerelease)$script:lineEnding$script:lineEnding" + $versionChangelog += "Incremental prerelease update.$script:lineEnding$script:lineEnding" + } else { + # For all other versions with no commits, create a placeholder message + $versionChangelog = "## $ToTag" + if ($versionType -ne "unknown") { + $versionChangelog += " ($versionType)" + } + $versionChangelog += "$script:lineEnding$script:lineEnding" + + if ($FromTag -eq "v0.0.0") { + $versionChangelog += "Initial release.$script:lineEnding$script:lineEnding" + } else { + $versionChangelog += "No significant changes detected since $FromTag.$script:lineEnding$script:lineEnding" + } + } + + return ($versionChangelog.Trim() + $script:lineEnding) +} + +function New-Changelog { + <# + .SYNOPSIS + Creates a complete changelog file. + .DESCRIPTION + Generates a comprehensive CHANGELOG.md with entries for all versions. + .PARAMETER Version + The current version number being released. + .PARAMETER CommitHash + The Git commit hash being released. + .PARAMETER OutputPath + Optional path to write the changelog file to. Defaults to workspace root. + .PARAMETER IncludeAllVersions + Whether to include all previous versions in the changelog. Defaults to $true. + .PARAMETER LatestChangelogFile + Optional path to write the latest version's changelog to. Defaults to "LATEST_CHANGELOG.md". + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Version, + [Parameter(Mandatory=$true)] + [string]$CommitHash, + [string]$OutputPath = "", + [bool]$IncludeAllVersions = $true, + [string]$LatestChangelogFile = "LATEST_CHANGELOG.md" + ) + + # Configure git versionsort to correctly handle prereleases + $suffixes = @('-alpha', '-beta', '-rc', '-pre') + foreach ($suffix in $suffixes) { + "git config versionsort.suffix `"$suffix`"" | Invoke-ExpressionWithLogging -Tags "Get-GitTags" | Write-InformationStream -Tags "Get-GitTags" + } + + # Get all tags sorted by version + $tags = Get-GitTags + $changelog = "" + + # Make sure tags is always an array + $tags = ConvertTo-ArraySafe -InputObject $tags + + # Check if we have any tags at all + $hasTags = $tags.Count -gt 0 + + # For first release, there's no previous tag to compare against + $previousTag = 'v0.0.0' + + # If we have tags, find the most recent one to compare against + if ($hasTags) { + $previousTag = $tags[0] # Most recent tag + } + + # Always add entry for current/new version (comparing current commit to previous tag or initial state) + $currentTag = "v$Version" + Write-Information "Generating changelog from $previousTag to $currentTag (commit: $CommitHash)" -Tags "New-Changelog" + $versionNotes = Get-VersionNotes -Tags $tags -FromTag $previousTag -ToTag $currentTag -ToSha $CommitHash + + # Store the latest version's notes for later use in GitHub releases + $latestVersionNotes = "" + + # If we have changes, add them to the changelog + if (-not [string]::IsNullOrWhiteSpace($versionNotes)) { + $changelog += $versionNotes + $latestVersionNotes = $versionNotes + } else { + # Handle no changes detected case - add a minimal entry + $minimalEntry = "## $currentTag$script:lineEnding$script:lineEnding" + $minimalEntry += "Initial release or no significant changes since $previousTag.$script:lineEnding$script:lineEnding" + + $changelog += $minimalEntry + $latestVersionNotes = $minimalEntry + } + + # Add entries for all previous versions if requested + if ($IncludeAllVersions -and $hasTags) { + $tagIndex = 0 + + foreach ($tag in $tags) { + if ($tag -like "v*") { + $previousTag = "v0.0.0" + if ($tagIndex -lt $tags.Count - 1) { + $previousTag = $tags[$tagIndex + 1] + } + + if (-not ($previousTag -like "v*")) { + $previousTag = "v0.0.0" + } + + $versionNotes = Get-VersionNotes -Tags $tags -FromTag $previousTag -ToTag $tag + $changelog += $versionNotes + } + $tagIndex++ + } + } + + # Write changelog to file + $filePath = if ($OutputPath) { Join-Path $OutputPath "CHANGELOG.md" } else { "CHANGELOG.md" } + + # Normalize line endings in changelog content + $changelog = $changelog.ReplaceLineEndings($script:lineEnding) + + [System.IO.File]::WriteAllText($filePath, $changelog, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-Changelog" + + # Write latest version's changelog to separate file for GitHub releases + $latestPath = if ($OutputPath) { Join-Path $OutputPath $LatestChangelogFile } else { $LatestChangelogFile } + $latestVersionNotes = $latestVersionNotes.ReplaceLineEndings($script:lineEnding) + [System.IO.File]::WriteAllText($latestPath, $latestVersionNotes, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-Changelog" + Write-Information "Latest version changelog saved to: $latestPath" -Tags "New-Changelog" + + $versionCount = if ($hasTags) { $tags.Count + 1 } else { 1 } + Write-Information "Changelog generated with entries for $versionCount versions" -Tags "New-Changelog" +} + +#endregion + +#region Metadata Management + +function Update-ProjectMetadata { + <# + .SYNOPSIS + Updates project metadata files based on build configuration. + .DESCRIPTION + Generates and updates version information, license, changelog, and other metadata files for a project. + This function centralizes all metadata generation to ensure consistency across project documentation. + .PARAMETER BuildConfiguration + The build configuration object containing paths, version info, and GitHub details. + Should be obtained from Get-BuildConfiguration. + .PARAMETER Authors + Optional array of author names to include in the AUTHORS.md file. + .PARAMETER CommitMessage + Optional commit message to use when committing metadata changes. + Defaults to "[bot][skip ci] Update Metadata". + .EXAMPLE + $config = Get-BuildConfiguration -GitRef "refs/heads/main" -GitSha "abc123" -GitHubOwner "myorg" -GitHubRepo "myproject" + Update-ProjectMetadata -BuildConfiguration $config + .EXAMPLE + Update-ProjectMetadata -BuildConfiguration $config -Authors @("Developer 1", "Developer 2") -CommitMessage "Update project documentation" + .OUTPUTS + PSCustomObject with Success, Error, and Data properties. + Data contains Version, ReleaseHash, and HasChanges information. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$BuildConfiguration, + [Parameter(Mandatory = $false)] + [string[]]$Authors = @(), + [Parameter(Mandatory = $false)] + [string]$CommitMessage = "[bot][skip ci] Update Metadata" + ) + + try { + Write-Information "Generating version information..." -Tags "Update-ProjectMetadata" + $version = New-Version -CommitHash $BuildConfiguration.ReleaseHash + Write-Information "Version: $version" -Tags "Update-ProjectMetadata" + + Write-Information "Generating license..." -Tags "Update-ProjectMetadata" + New-License -ServerUrl $BuildConfiguration.ServerUrl -Owner $BuildConfiguration.GitHubOwner -Repository $BuildConfiguration.GitHubRepo | Write-InformationStream -Tags "Update-ProjectMetadata" + + Write-Information "Generating changelog..." -Tags "Update-ProjectMetadata" + # Generate both full changelog and latest version changelog + try { + New-Changelog -Version $version -CommitHash $BuildConfiguration.ReleaseHash -LatestChangelogFile $BuildConfiguration.LatestChangelogFile | Write-InformationStream -Tags "Update-ProjectMetadata" + } + catch { + $errorMessage = $_.ToString() + Write-Information "Failed to generate complete changelog: $errorMessage" -Tags "Update-ProjectMetadata" + Write-Information "Creating minimal changelog instead..." -Tags "Update-ProjectMetadata" + + # Create a minimal changelog + $minimalChangelog = "## v$version$($script:lineEnding)$($script:lineEnding)" + $minimalChangelog += "Initial release or repository with no prior history.$($script:lineEnding)$($script:lineEnding)" + + [System.IO.File]::WriteAllText("CHANGELOG.md", $minimalChangelog, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" + [System.IO.File]::WriteAllText($BuildConfiguration.LatestChangelogFile, $minimalChangelog, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" + } + + # Create AUTHORS.md if authors are provided + if (@($Authors).Count -gt 0) { + Write-Information "Generating authors file..." -Tags "Update-ProjectMetadata" + $authorsContent = "# Project Authors$script:lineEnding$script:lineEnding" + foreach ($author in $Authors) { + $authorsContent += "* $author$script:lineEnding" + } + [System.IO.File]::WriteAllText("AUTHORS.md", $authorsContent, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" + } + + # Create AUTHORS.url + $authorsUrl = "[InternetShortcut]$($script:lineEnding)URL=$($BuildConfiguration.ServerUrl)/$($BuildConfiguration.GitHubOwner)" + [System.IO.File]::WriteAllText("AUTHORS.url", $authorsUrl, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" + + # Create PROJECT_URL.url + $projectUrl = "[InternetShortcut]$($script:lineEnding)URL=$($BuildConfiguration.ServerUrl)/$($BuildConfiguration.GitHubRepo)" + [System.IO.File]::WriteAllText("PROJECT_URL.url", $projectUrl, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" + + Write-Information "Adding files to git..." -Tags "Update-ProjectMetadata" + $filesToAdd = @( + "VERSION.md", + "LICENSE.md", + "AUTHORS.md", + "CHANGELOG.md", + "COPYRIGHT.md", + "PROJECT_URL.url", + "AUTHORS.url" + ) + + # Add latest changelog if it exists + if (Test-Path $BuildConfiguration.LatestChangelogFile) { + $filesToAdd += $BuildConfiguration.LatestChangelogFile + } + Write-Information "Files to add: $($filesToAdd -join ", ")" -Tags "Update-ProjectMetadata" + "git add $filesToAdd" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Update-ProjectMetadata" + + Write-Information "Checking for changes to commit..." -Tags "Update-ProjectMetadata" + $postStatus = "git status --porcelain" | Invoke-ExpressionWithLogging -Tags "Update-ProjectMetadata" | Out-String + $hasChanges = -not [string]::IsNullOrWhiteSpace($postStatus) + $statusMessage = if ($hasChanges) { 'Changes detected' } else { 'No changes' } + Write-Information "Git status: $statusMessage" -Tags "Update-ProjectMetadata" + + # Get the current commit hash regardless of whether we make changes + $currentHash = "git rev-parse HEAD" | Invoke-ExpressionWithLogging + Write-Information "Current commit hash: $currentHash" -Tags "Update-ProjectMetadata" + + if (-not [string]::IsNullOrWhiteSpace($postStatus)) { + # Configure git user before committing + Set-GitIdentity | Write-InformationStream -Tags "Update-ProjectMetadata" + + Write-Information "Committing changes..." -Tags "Update-ProjectMetadata" + "git commit -m `"$CommitMessage`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Update-ProjectMetadata" + + Write-Information "Pushing changes..." -Tags "Update-ProjectMetadata" + "git push" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Update-ProjectMetadata" + + Write-Information "Getting release hash..." -Tags "Update-ProjectMetadata" + $releaseHash = "git rev-parse HEAD" | Invoke-ExpressionWithLogging + Write-Information "Metadata committed as $releaseHash" -Tags "Update-ProjectMetadata" + + Write-Information "Metadata update completed successfully with changes" -Tags "Update-ProjectMetadata" + Write-Information "Version: $version" -Tags "Update-ProjectMetadata" + Write-Information "Release Hash: $releaseHash" -Tags "Update-ProjectMetadata" + + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $version + ReleaseHash = $releaseHash + HasChanges = $true + } + } + } + else { + Write-Information "No changes to commit" -Tags "Update-ProjectMetadata" + Write-Information "Version: $version" -Tags "Update-ProjectMetadata" + Write-Information "Using current commit hash: $currentHash" -Tags "Update-ProjectMetadata" + + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $version + ReleaseHash = $currentHash + HasChanges = $false + } + } + } + } + catch { + $errorMessage = $_.ToString() + Write-Information "Failed to update metadata: $errorMessage" -Tags "Update-ProjectMetadata" + return [PSCustomObject]@{ + Success = $false + Error = $errorMessage + Data = [PSCustomObject]@{ + Version = $null + ReleaseHash = $null + HasChanges = $false + StackTrace = $_.ScriptStackTrace + } + } + } +} + +#endregion + +#region Build Operations + +function Invoke-DotNetRestore { + <# + .SYNOPSIS + Restores NuGet packages. + .DESCRIPTION + Runs dotnet restore to get all dependencies. + #> + [CmdletBinding()] + param() + + Write-StepHeader "Restoring Dependencies" -Tags "Invoke-DotNetRestore" + + # Execute command and stream output directly to console + "dotnet restore --locked-mode -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetRestore" + Assert-LastExitCode "Restore failed" +} + +function Invoke-DotNetBuild { + <# + .SYNOPSIS + Builds the .NET solution. + .DESCRIPTION + Runs dotnet build with specified configuration. + .PARAMETER Configuration + The build configuration (Debug/Release). + .PARAMETER BuildArgs + Additional build arguments. + #> + [CmdletBinding()] + param ( + [string]$Configuration = "Release", + [string]$BuildArgs = "" + ) + + Write-StepHeader "Building Solution" -Tags "Invoke-DotNetBuild" + + try { + # First attempt with quiet verbosity - stream output directly + "dotnet build --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-incremental $BuildArgs --no-restore" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetBuild" + + if ($LASTEXITCODE -ne 0) { + Write-Information "Build failed with exit code $LASTEXITCODE. Retrying with detailed verbosity..." -Tags "Invoke-DotNetBuild" + + # Retry with more detailed verbosity - stream output directly + "dotnet build --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-incremental $BuildArgs --no-restore" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetBuild" + + # Still failed, show diagnostic info and throw error + if ($LASTEXITCODE -ne 0) { + Write-Information "Checking for common build issues:" -Tags "Invoke-DotNetBuild" + + # Check for project files + $projectFiles = @(Get-ChildItem -Recurse -Filter *.csproj) + Write-Information "Found $($projectFiles.Count) project files" -Tags "Invoke-DotNetBuild" + + foreach ($proj in $projectFiles) { + Write-Information " - $($proj.FullName)" -Tags "Invoke-DotNetBuild" + } + + Assert-LastExitCode "Build failed" + } + } + } + catch { + Write-Information "Exception during build process: $_" -Tags "Invoke-DotNetBuild" + throw + } +} + +function Invoke-DotNetTest { + <# + .SYNOPSIS + Runs dotnet test with code coverage collection. + .DESCRIPTION + Runs dotnet test with code coverage collection. + .PARAMETER Configuration + The build configuration to use. + .PARAMETER CoverageOutputPath + The path to output code coverage results. + #> + [CmdletBinding()] + param ( + [string]$Configuration = "Release", + [string]$CoverageOutputPath = "coverage" + ) + + Write-StepHeader "Running Tests with Coverage" -Tags "Invoke-DotNetTest" + + # Ensure the TestResults directory exists + $testResultsPath = Join-Path $CoverageOutputPath "TestResults" + New-Item -Path $testResultsPath -ItemType Directory -Force | Out-Null + + # Run tests with both coverage collection and TRX logging for SonarQube + "dotnet test --configuration $Configuration /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=`"coverage.opencover.xml`" --results-directory `"$testResultsPath`" --logger `"trx;LogFileName=TestResults.trx`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetTest" + Assert-LastExitCode "Tests failed" + + # Find and copy coverage file to expected location for SonarQube + $coverageFiles = @(Get-ChildItem -Path . -Recurse -Filter "coverage.opencover.xml" -ErrorAction SilentlyContinue) + if ($coverageFiles.Count -gt 0) { + $latestCoverageFile = $coverageFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + $targetCoverageFile = Join-Path $CoverageOutputPath "coverage.opencover.xml" + Copy-Item -Path $latestCoverageFile.FullName -Destination $targetCoverageFile -Force + Write-Information "Coverage file copied to: $targetCoverageFile" -Tags "Invoke-DotNetTest" + } else { + Write-Information "Warning: No coverage file found" -Tags "Invoke-DotNetTest" + } +} + +function Invoke-DotNetPack { + <# + .SYNOPSIS + Creates NuGet packages. + .DESCRIPTION + Runs dotnet pack to create NuGet packages. + .PARAMETER Configuration + The build configuration (Debug/Release). + .PARAMETER OutputPath + The path to output packages to. + .PARAMETER Project + Optional specific project to package. If not provided, all projects are packaged. + .PARAMETER LatestChangelogFile + Optional path to the latest changelog file to use for PackageReleaseNotes. Defaults to "LATEST_CHANGELOG.md". + #> + [CmdletBinding()] + param ( + [string]$Configuration = "Release", + [Parameter(Mandatory=$true)] + [string]$OutputPath, + [string]$Project = "", + [string]$LatestChangelogFile = "LATEST_CHANGELOG.md" + ) + + Write-StepHeader "Packaging Libraries" -Tags "Invoke-DotNetPack" + + # Ensure output directory exists + New-Item -Path $OutputPath -ItemType Directory -Force | Write-InformationStream -Tags "Invoke-DotNetPack" + + # Check if any projects exist + $projectFiles = @(Get-ChildItem -Recurse -Filter *.csproj -ErrorAction SilentlyContinue) + if ($projectFiles.Count -eq 0) { + Write-Information "No .NET library projects found to package" -Tags "Invoke-DotNetPack" + return + } + + try { + # Prepare PackageReleaseNotesFile property if latest changelog exists + $releaseNotesProperty = "" + if (Test-Path $LatestChangelogFile) { + # Use PackageReleaseNotesFile to reference the file path instead of inline content + # This avoids command-line parsing issues with special characters like semicolons + $absolutePath = (Resolve-Path $LatestChangelogFile).Path + $releaseNotesProperty = "-p:PackageReleaseNotesFile=`"$absolutePath`"" + Write-Information "Using PackageReleaseNotesFile from $LatestChangelogFile" -Tags "Invoke-DotNetPack" + } + + # Build either a specific project or all projects + if ([string]::IsNullOrWhiteSpace($Project)) { + Write-Information "Packaging all projects in solution..." -Tags "Invoke-DotNetPack" + "dotnet pack --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-build --output $OutputPath $releaseNotesProperty" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPack" + } else { + Write-Information "Packaging project: $Project" -Tags "Invoke-DotNetPack" + "dotnet pack $Project --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-build --output $OutputPath $releaseNotesProperty" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPack" + } + + if ($LASTEXITCODE -ne 0) { + # Get more details about what might have failed + Write-Information "Packaging failed with exit code $LASTEXITCODE, trying again with detailed verbosity..." -Tags "Invoke-DotNetPack" + "dotnet pack --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=detailed`" --no-build --output $OutputPath $releaseNotesProperty" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPack" + throw "Library packaging failed with exit code $LASTEXITCODE" + } + + # Report on created packages + $packages = @(Get-ChildItem -Path $OutputPath -Filter *.nupkg -ErrorAction SilentlyContinue) + if ($packages.Count -gt 0) { + Write-Information "Created $($packages.Count) packages in $OutputPath" -Tags "Invoke-DotNetPack" + foreach ($package in $packages) { + Write-Information " - $($package.Name)" -Tags "Invoke-DotNetPack" + } + } else { + Write-Information "No packages were created (projects may not be configured for packaging)" -Tags "Invoke-DotNetPack" + } + } + catch { + $originalException = $_.Exception + Write-Information "Package creation failed: $originalException" -Tags "Invoke-DotNetPack" + throw "Library packaging failed: $originalException" + } +} + +function Invoke-DotNetPublish { + <# + .SYNOPSIS + Publishes .NET applications and creates winget-compatible packages. + .DESCRIPTION + Runs dotnet publish and creates zip archives for applications. + Also creates winget-compatible packages for multiple architectures if console applications are found. + Uses the build configuration to determine output paths and version information. + .PARAMETER Configuration + The build configuration (Debug/Release). Defaults to "Release". + .PARAMETER BuildConfiguration + The build configuration object containing output paths, version, and other settings. + This object should be obtained from Get-BuildConfiguration. + .OUTPUTS + None. Creates published applications, zip archives, and winget packages in the specified output paths. + #> + [CmdletBinding()] + param ( + [string]$Configuration = "Release", + [Parameter(Mandatory=$true)] + [PSCustomObject]$BuildConfiguration + ) + + Write-StepHeader "Publishing Applications" -Tags "Invoke-DotNetPublish" + + # Find all projects + $projectFiles = @(Get-ChildItem -Recurse -Filter *.csproj -ErrorAction SilentlyContinue) + if ($projectFiles.Count -eq 0) { + Write-Information "No .NET application projects found to publish" -Tags "Invoke-DotNetPublish" + return + } + + # Clean output directory if it exists + if (Test-Path $BuildConfiguration.OutputPath) { + Remove-Item -Recurse -Force $BuildConfiguration.OutputPath | Write-InformationStream -Tags "Invoke-DotNetPublish" + } + + # Ensure staging directory exists + New-Item -Path $BuildConfiguration.StagingPath -ItemType Directory -Force | Write-InformationStream -Tags "Invoke-DotNetPublish" + + $publishedCount = 0 + $version = $BuildConfiguration.Version + + # Define target architectures for comprehensive publishing across all platforms + $architectures = @( + # Windows + "win-x64", "win-x86", "win-arm64", + # Linux + "linux-x64", "linux-arm64", + # macOS + "osx-x64", "osx-arm64" + ) + + foreach ($csproj in $projectFiles) { + $projName = [System.IO.Path]::GetFileNameWithoutExtension($csproj) + Write-Information "Publishing $projName..." -Tags "Invoke-DotNetPublish" + + foreach ($arch in $architectures) { + $outDir = Join-Path $BuildConfiguration.OutputPath "$projName-$arch" + + # Create output directory + New-Item -Path $outDir -ItemType Directory -Force | Write-InformationStream -Tags "Invoke-DotNetPublish" + + # Publish application with optimized settings for both general use and winget compatibility + "dotnet publish `"$csproj`" --configuration $Configuration --runtime $arch --self-contained true --output `"$outDir`" -p:PublishSingleFile=true -p:PublishTrimmed=false -p:EnableCompressionInSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=none -p:DebugSymbols=false -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPublish" + + if ($LASTEXITCODE -eq 0) { + # Create general application zip archive for all platforms + $stageFile = Join-Path $BuildConfiguration.StagingPath "$projName-$version-$arch.zip" + Compress-Archive -Path "$outDir/*" -DestinationPath $stageFile -Force | Write-InformationStream -Tags "Invoke-DotNetPublish" + + $publishedCount++ + Write-Information "Successfully published $projName for $arch" -Tags "Invoke-DotNetPublish" + } else { + Write-Information "Failed to publish $projName for $arch" -Tags "Invoke-DotNetPublish" + continue + } + } + } + + # Generate SHA256 hashes for all published packages + $allPackages = @(Get-ChildItem -Path $BuildConfiguration.StagingPath -Filter "*.zip" -ErrorAction SilentlyContinue) + + if ($allPackages.Count -gt 0) { + Write-Information "Generating SHA256 hashes for all published packages..." -Tags "Invoke-DotNetPublish" + + foreach ($package in $allPackages) { + # Calculate and store SHA256 hash + $hash = Get-FileHash -Path $package.FullName -Algorithm SHA256 + Write-Information "SHA256 for $($package.Name): $($hash.Hash)" -Tags "Invoke-DotNetPublish" + + # Store hash for integrity verification and distribution use + "$($package.Name)=$($hash.Hash)" | Out-File -FilePath (Join-Path $BuildConfiguration.StagingPath "hashes.txt") -Append -Encoding UTF8 + } + } + + if ($publishedCount -gt 0) { + Write-Information "Published $publishedCount application packages across all platforms and architectures" -Tags "Invoke-DotNetPublish" + + # Report hash generation results + if ($allPackages.Count -gt 0) { + Write-Information "Generated SHA256 hashes for $($allPackages.Count) published packages" -Tags "Invoke-DotNetPublish" + } + } else { + Write-Information "No applications were published (projects may not be configured as executables)" -Tags "Invoke-DotNetPublish" + } + + # Note: NuGet package publishing is handled separately in Invoke-ReleaseWorkflow + + Write-StepHeader "Release Process Completed" -Tags "Invoke-ReleaseWorkflow" + Write-Information "Release process completed successfully!" -Tags "Invoke-ReleaseWorkflow" + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $BuildConfiguration.Version + ReleaseHash = $BuildConfiguration.ReleaseHash + PackagePaths = @() + } + } +} + +#endregion + +#region Publishing and Release + +function Invoke-NuGetPublish { + <# + .SYNOPSIS + Publishes NuGet packages. + .DESCRIPTION + Publishes packages to GitHub Packages and NuGet.org. + Uses the build configuration to determine package paths and authentication details. + .PARAMETER BuildConfiguration + The build configuration object containing package patterns, GitHub token, and NuGet API key. + This object should be obtained from Get-BuildConfiguration. + .OUTPUTS + None. Publishes packages to the configured package repositories. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [PSCustomObject]$BuildConfiguration + ) + + # Check if there are any packages to publish + $packages = @(Get-Item -Path $BuildConfiguration.PackagePattern -ErrorAction SilentlyContinue) + if ($packages.Count -eq 0) { + Write-Information "No packages found to publish" -Tags "Invoke-NuGetPublish" + return + } + + Write-Information "Found $($packages.Count) package(s) to publish" -Tags "Invoke-NuGetPublish" + + Write-StepHeader "Publishing to GitHub Packages" -Tags "Invoke-NuGetPublish" + + # Execute the command and stream output + "dotnet nuget push `"$($BuildConfiguration.PackagePattern)`" --api-key `"$($BuildConfiguration.GithubToken)`" --source `"https://nuget.pkg.github.com/$($BuildConfiguration.GithubOwner)/index.json`" --skip-duplicate" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-NuGetPublish" + Assert-LastExitCode "GitHub package publish failed" + + Write-StepHeader "Publishing to NuGet.org" -Tags "Invoke-NuGetPublish" + + # Execute the command and stream output + "dotnet nuget push `"$($BuildConfiguration.PackagePattern)`" --api-key `"$($BuildConfiguration.NuGetApiKey)`" --source `"https://api.nuget.org/v3/index.json`" --skip-duplicate" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-NuGetPublish" + Assert-LastExitCode "NuGet.org package publish failed" +} + +function New-GitHubRelease { + <# + .SYNOPSIS + Creates a new GitHub release. + .DESCRIPTION + Creates a new GitHub release with the specified version, creates and pushes a git tag, + and uploads release assets. Uses the GitHub CLI (gh) for release creation. + .PARAMETER BuildConfiguration + The build configuration object containing version, commit hash, GitHub token, and asset patterns. + This object should be obtained from Get-BuildConfiguration. + .OUTPUTS + None. Creates a GitHub release and uploads specified assets. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [PSCustomObject]$BuildConfiguration + ) + + # Set GitHub token for CLI + $env:GH_TOKEN = $BuildConfiguration.GithubToken + + # Configure git user + Set-GitIdentity | Write-InformationStream -Tags "New-GitHubRelease" + + # Create and push the tag first + Write-Information "Creating and pushing tag v$($BuildConfiguration.Version)..." -Tags "New-GitHubRelease" + "git tag -a `"v$($BuildConfiguration.Version)`" `"$($BuildConfiguration.ReleaseHash)`" -m `"Release v$($BuildConfiguration.Version)`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "New-GitHubRelease" + Assert-LastExitCode "Failed to create git tag" + + "git push origin `"v$($BuildConfiguration.Version)`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "New-GitHubRelease" + Assert-LastExitCode "Failed to push git tag" + + # Collect all assets + $assets = @() + foreach ($pattern in $BuildConfiguration.AssetPatterns) { + $matched = Get-Item -Path $pattern -ErrorAction SilentlyContinue + if ($matched) { + $assets += $matched.FullName + } + } + + # Create release + Write-StepHeader "Creating GitHub Release v$($BuildConfiguration.Version)" -Tags "New-GitHubRelease" + + $releaseArgs = @( + "release", + "create", + "v$($BuildConfiguration.Version)" + ) + + # Add target commit + $releaseArgs += "--target" + $releaseArgs += $BuildConfiguration.ReleaseHash.ToString() + + # Add notes generation + $releaseArgs += "--generate-notes" + + # First check for latest changelog file (preferred for releases) + $latestChangelogPath = "LATEST_CHANGELOG.md" + if (Test-Path $latestChangelogPath) { + Write-Information "Using latest version changelog from $latestChangelogPath" -Tags "New-GitHubRelease" + $releaseArgs += "--notes-file" + $releaseArgs += $latestChangelogPath + } + # Fall back to full changelog if specified in config and latest not found + elseif (Test-Path $BuildConfiguration.ChangelogFile) { + Write-Information "Using full changelog from $($BuildConfiguration.ChangelogFile)" -Tags "New-GitHubRelease" + $releaseArgs += "--notes-file" + $releaseArgs += $BuildConfiguration.ChangelogFile + } + + # Add assets as positional arguments + $releaseArgs += $assets + + # Join the arguments into a single string + $releaseArgs = $releaseArgs -join ' ' + + "gh $releaseArgs" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "New-GitHubRelease" + Assert-LastExitCode "Failed to create GitHub release" +} + +#endregion + +#region Utility Functions + +function Assert-LastExitCode { + <# + .SYNOPSIS + Verifies that the last command executed successfully. + .DESCRIPTION + Throws an exception if the last command execution resulted in a non-zero exit code. + This function is used internally to ensure each step completes successfully. + .PARAMETER Message + The error message to display if the exit code check fails. + .PARAMETER Command + Optional. The command that was executed, for better error reporting. + .EXAMPLE + dotnet build + Assert-LastExitCode "The build process failed" -Command "dotnet build" + .NOTES + Author: ktsu.dev + #> + [CmdletBinding()] + param ( + [string]$Message = "Command failed", + [string]$Command = "" + ) + + if ($LASTEXITCODE -ne 0) { + $errorDetails = "Exit code: $LASTEXITCODE" + if (-not [string]::IsNullOrWhiteSpace($Command)) { + $errorDetails += " | Command: $Command" + } + + $fullMessage = "$Message$script:lineEnding$errorDetails" + Write-Information $fullMessage -Tags "Assert-LastExitCode" + throw $fullMessage + } +} + +function Write-StepHeader { + <# + .SYNOPSIS + Writes a formatted step header to the console. + .DESCRIPTION + Creates a visually distinct header for build steps in the console output. + Used to improve readability of the build process logs. + .PARAMETER Message + The header message to display. + .EXAMPLE + Write-StepHeader "Restoring Packages" + .NOTES + Author: ktsu.dev + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Message, + [Parameter()] + [AllowEmptyCollection()] + [string[]]$Tags = @("Write-StepHeader") + ) + Write-Information "$($script:lineEnding)=== $Message ===$($script:lineEnding)" -Tags $Tags +} + +function Test-AnyFiles { + <# + .SYNOPSIS + Tests if any files match the specified pattern. + .DESCRIPTION + Tests if any files exist that match the given glob pattern. This is useful for + determining if certain file types (like packages) exist before attempting operations + on them. + .PARAMETER Pattern + The glob pattern to check for matching files. + .EXAMPLE + if (Test-AnyFiles -Pattern "*.nupkg") { + Write-Host "NuGet packages found!" + } + .NOTES + Author: ktsu.dev + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory=$true)] + [string]$Pattern + ) + + # Use array subexpression to ensure consistent collection handling + $matchingFiles = @(Get-Item -Path $Pattern -ErrorAction SilentlyContinue) + return $matchingFiles.Count -gt 0 +} + +function Write-InformationStream { + <# + .SYNOPSIS + Streams output to the console. + .DESCRIPTION + Streams output to the console. + .PARAMETER Object + The object to write to the console. + .EXAMPLE + & git status | Write-InformationStream + .NOTES + Author: ktsu.dev + #> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline=$true, ParameterSetName="Object")] + [object]$Object, + [Parameter()] + [AllowEmptyCollection()] + [string[]]$Tags = @("Write-InformationStream") + ) + + process { + # Use array subexpression to ensure consistent collection handling + $Object | ForEach-Object { + Write-Information $_ -Tags $Tags + } + } +} + +function Invoke-ExpressionWithLogging { + <# + .SYNOPSIS + Invokes an expression and logs the result to the console. + .DESCRIPTION + Invokes an expression and logs the result to the console. + .PARAMETER ScriptBlock + The script block to execute. + .PARAMETER Command + A string command to execute, which will be converted to a script block. + .PARAMETER Tags + Optional tags to include in the logging output for filtering and organization. + .OUTPUTS + The result of the expression. + .NOTES + Author: ktsu.dev + This function is useful for debugging expressions that are not returning the expected results. + #> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline=$true, ParameterSetName="ScriptBlock")] + [scriptblock]$ScriptBlock, + + [Parameter(ValueFromPipeline=$true, ParameterSetName="Command")] + [string]$Command, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$Tags = @("Invoke-ExpressionWithLogging") + ) + + process { + # Convert command string to scriptblock if needed + if ($PSCmdlet.ParameterSetName -eq "Command" -and -not [string]::IsNullOrWhiteSpace($Command)) { + Write-Information "Executing command: $Command" -Tags $Tags + $ScriptBlock = [scriptblock]::Create($Command) + } + else { + Write-Information "Executing script block: $ScriptBlock" -Tags $Tags + } + + if ($ScriptBlock) { + # Execute the expression and return its result + & $ScriptBlock | ForEach-Object { + Write-Output $_ + } + } + } +} + +function Get-GitLineEnding { + <# + .SYNOPSIS + Gets the correct line ending based on git config. + .DESCRIPTION + Determines whether to use LF or CRLF based on the git core.autocrlf and core.eol settings. + Falls back to system default line ending if no git settings are found. + .OUTPUTS + String. Returns either "`n" for LF or "`r`n" for CRLF line endings. + .NOTES + The function checks git settings in the following order: + 1. core.eol setting (if set to 'lf' or 'crlf') + 2. core.autocrlf setting ('true', 'input', or 'false') + 3. System default line ending + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $autocrlf = "git config --get core.autocrlf" | Invoke-ExpressionWithLogging + $eol = "git config --get core.eol" | Invoke-ExpressionWithLogging + + # If core.eol is set, use that + if ($LASTEXITCODE -eq 0 -and $eol -in @('lf', 'crlf')) { + return if ($eol -eq 'lf') { "`n" } else { "`r`n" } + } + + # Otherwise use autocrlf setting + if ($LASTEXITCODE -eq 0) { + switch ($autocrlf.ToLower()) { + 'true' { return "`n" } # Git will convert to CRLF on checkout + 'input' { return "`n" } # Always use LF + 'false' { + # Use OS default + return [System.Environment]::NewLine + } + default { + # Default to OS line ending if setting is not recognized + return [System.Environment]::NewLine + } + } + } + + # If git config fails or no setting found, use OS default + return [System.Environment]::NewLine +} + +function Set-GitIdentity { + <# + .SYNOPSIS + Configures git user identity for automated operations. + .DESCRIPTION + Sets up git user name and email globally for GitHub Actions or other automated processes. + #> + [CmdletBinding()] + param() + + Write-Information "Configuring git user for GitHub Actions..." -Tags "Set-GitIdentity" + "git config --global user.name `"Github Actions`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Set-GitIdentity" + Assert-LastExitCode "Failed to configure git user name" + "git config --global user.email `"actions@users.noreply.github.com`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Set-GitIdentity" + Assert-LastExitCode "Failed to configure git user email" +} + +function ConvertTo-ArraySafe { + <# + .SYNOPSIS + Safely converts an object to an array, even if it's already an array, a single item, or null. + .DESCRIPTION + Ensures that the returned object is always an array, handling PowerShell's behavior + where single item arrays are automatically unwrapped. Also handles error objects and other edge cases. + .PARAMETER InputObject + The object to convert to an array. + .OUTPUTS + Returns an array, even if the input is null or a single item. + #> + [CmdletBinding()] + [OutputType([object[]])] + param ( + [Parameter(ValueFromPipeline=$true)] + [AllowNull()] + [object]$InputObject + ) + + # Handle null or empty input + if ($null -eq $InputObject -or [string]::IsNullOrEmpty($InputObject)) { + return ,[object[]]@() + } + + # Handle error objects - return empty array for safety + if ($InputObject -is [System.Management.Automation.ErrorRecord]) { + Write-Information "ConvertTo-ArraySafe: Received error object, returning empty array" -Tags "ConvertTo-ArraySafe" + return ,[object[]]@() + } + + # Handle empty strings + if ($InputObject -is [string] -and [string]::IsNullOrWhiteSpace($InputObject)) { + return ,[object[]]@() + } + + try { + # Always force array context using the comma operator and explicit array subexpression + if ($InputObject -is [array]) { + # Ensure we return a proper array even if it's a single-item array + return ,[object[]]@($InputObject) + } + elseif ($InputObject -is [string] -and $InputObject.Contains("`n")) { + # Handle multi-line strings by splitting them + $lines = $InputObject -split "`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + return ,[object[]]@($lines) + } + else { + # Single item, make it an array using explicit array operators + return ,[object[]]@($InputObject) + } + } + catch { + Write-Information "ConvertTo-ArraySafe: Error converting object to array: $_" -Tags "ConvertTo-ArraySafe" + return ,[object[]]@() + } +} + +#endregion + +#region High-Level Workflows + +function Invoke-BuildWorkflow { + <# + .SYNOPSIS + Executes the main build workflow. + .DESCRIPTION + Runs the complete build, test, and package process. + .PARAMETER Configuration + The build configuration (Debug/Release). + .PARAMETER BuildArgs + Additional build arguments. + .PARAMETER BuildConfiguration + The build configuration object from Get-BuildConfiguration. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [string]$Configuration = "Release", + [string]$BuildArgs = "", + [Parameter(Mandatory=$true)] + [PSCustomObject]$BuildConfiguration + ) + + try { + # Setup + Initialize-BuildEnvironment | Write-InformationStream -Tags "Invoke-BuildWorkflow" + + # Install dotnet-script if needed + if ($BuildConfiguration.UseDotnetScript) { + Write-StepHeader "Installing dotnet-script" -Tags "Invoke-DotnetScript" + "dotnet tool install -g dotnet-script" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotnetScript" + Assert-LastExitCode "Failed to install dotnet-script" + } + + # Build and Test + Invoke-DotNetRestore | Write-InformationStream -Tags "Invoke-BuildWorkflow" + Invoke-DotNetBuild -Configuration $Configuration -BuildArgs $BuildArgs | Write-InformationStream -Tags "Invoke-BuildWorkflow" + Invoke-DotNetTest -Configuration $Configuration -CoverageOutputPath "coverage" | Write-InformationStream -Tags "Invoke-BuildWorkflow" + + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Configuration = $Configuration + BuildArgs = $BuildArgs + } + } + } + catch { + Write-Information "Build workflow failed: $_" -Tags "Invoke-BuildWorkflow" + return [PSCustomObject]@{ + Success = $false + Error = $_.ToString() + Data = [PSCustomObject]@{} + StackTrace = $_.ScriptStackTrace + } + } +} + +function Invoke-ReleaseWorkflow { + <# + .SYNOPSIS + Executes the release workflow. + .DESCRIPTION + Generates metadata, packages, and creates a release. + .PARAMETER Configuration + The build configuration (Debug/Release). Defaults to "Release". + .PARAMETER BuildConfiguration + The build configuration object from Get-BuildConfiguration. + .OUTPUTS + PSCustomObject with Success, Error, and Data properties. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [string]$Configuration = "Release", + [Parameter(Mandatory=$true)] + [PSCustomObject]$BuildConfiguration + ) + + try { + Write-StepHeader "Starting Release Process" -Tags "Invoke-ReleaseWorkflow" + + # Package and publish if not skipped + $packagePaths = @() + + # Create NuGet packages + try { + Write-StepHeader "Packaging Libraries" -Tags "Invoke-DotNetPack" + Invoke-DotNetPack -Configuration $Configuration -OutputPath $BuildConfiguration.StagingPath -LatestChangelogFile $BuildConfiguration.LatestChangelogFile | Write-InformationStream -Tags "Invoke-DotNetPack" + + # Add package paths if they exist + if (Test-Path $BuildConfiguration.PackagePattern) { + $packagePaths += $BuildConfiguration.PackagePattern + } + if (Test-Path $BuildConfiguration.SymbolsPattern) { + $packagePaths += $BuildConfiguration.SymbolsPattern + } + } + catch { + Write-Information "Library packaging failed: $_" -Tags "Invoke-DotNetPack" + Write-Information "Continuing with release process without NuGet packages." -Tags "Invoke-DotNetPack" + } + + # Create application packages + try { + Invoke-DotNetPublish -Configuration $Configuration -BuildConfiguration $BuildConfiguration | Write-InformationStream -Tags "Invoke-DotNetPublish" + + # Add application paths if they exist + if (Test-Path $BuildConfiguration.ApplicationPattern) { + $packagePaths += $BuildConfiguration.ApplicationPattern + } + + # Note: hashes.txt is now stored in staging directory alongside packages + } + catch { + Write-Information "Application publishing failed: $_" -Tags "Invoke-DotNetPublish" + Write-Information "Continuing with release process without application packages." -Tags "Invoke-DotNetPublish" + } + + # Publish packages if we have any and NuGet key is provided AND this is a release build + $packages = @(Get-Item -Path $BuildConfiguration.PackagePattern -ErrorAction SilentlyContinue) + if ($packages.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($BuildConfiguration.NuGetApiKey) -and $BuildConfiguration.ShouldRelease) { + Write-StepHeader "Publishing NuGet Packages" -Tags "Invoke-NuGetPublish" + try { + Invoke-NuGetPublish -BuildConfiguration $BuildConfiguration | Write-InformationStream -Tags "Invoke-NuGetPublish" + } + catch { + Write-Information "NuGet package publishing failed: $_" -Tags "Invoke-NuGetPublish" + Write-Information "Continuing with release process." -Tags "Invoke-NuGetPublish" + } + } elseif ($packages.Count -gt 0 -and -not $BuildConfiguration.ShouldRelease) { + Write-Information "Packages found but skipping publication (not a release build: ShouldRelease=$($BuildConfiguration.ShouldRelease))" -Tags "Invoke-ReleaseWorkflow" + } + + # Create GitHub release only if this is a release build + if ($BuildConfiguration.ShouldRelease) { + Write-StepHeader "Creating GitHub Release" -Tags "New-GitHubRelease" + Write-Information "Creating release for version $($BuildConfiguration.Version)..." -Tags "New-GitHubRelease" + New-GitHubRelease -BuildConfiguration $BuildConfiguration | Write-InformationStream -Tags "New-GitHubRelease" + } else { + Write-Information "Skipping GitHub release creation (not a release build: ShouldRelease=$($BuildConfiguration.ShouldRelease))" -Tags "Invoke-ReleaseWorkflow" + } + + Write-StepHeader "Release Process Completed" -Tags "Invoke-ReleaseWorkflow" + Write-Information "Release process completed successfully!" -Tags "Invoke-ReleaseWorkflow" + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $BuildConfiguration.Version + ReleaseHash = $BuildConfiguration.ReleaseHash + PackagePaths = $packagePaths + } + } + } + catch { + Write-Information "Release workflow failed: $_" -Tags "Invoke-ReleaseWorkflow" + return [PSCustomObject]@{ + Success = $false + Error = $_.ToString() + Data = [PSCustomObject]@{ + ErrorDetails = $_.Exception.Message + PackagePaths = @() + } + StackTrace = $_.ScriptStackTrace + } + } +} + +function Invoke-CIPipeline { + <# + .SYNOPSIS + Executes the CI/CD pipeline. + .DESCRIPTION + Executes the CI/CD pipeline, including metadata updates and build workflow. + .PARAMETER BuildConfiguration + The build configuration to use. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [Parameter(Mandatory=$true)] + [PSCustomObject]$BuildConfiguration + ) + + Write-Information "BuildConfiguration: $($BuildConfiguration | ConvertTo-Json -Depth 10)" -Tags "Invoke-CIPipeline" + + try { + Write-Information "Updating metadata..." -Tags "Invoke-CIPipeline" + $metadata = Update-ProjectMetadata ` + -BuildConfiguration $BuildConfiguration + + if ($null -eq $metadata) { + Write-Information "Metadata update returned null" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $false + Error = "Metadata update returned null" + StackTrace = $_.ScriptStackTrace + } + } + + Write-Information "Metadata: $($metadata | ConvertTo-Json -Depth 10)" -Tags "Invoke-CIPipeline" + + $BuildConfiguration.Version = $metadata.Data.Version + $BuildConfiguration.ReleaseHash = $metadata.Data.ReleaseHash + + if (-not $metadata.Success) { + Write-Information "Failed to update metadata: $($metadata.Error)" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $false + Error = "Failed to update metadata: $($metadata.Error)" + StackTrace = $_.ScriptStackTrace + } + } + + # Get the version increment info to check if we should skip the release + Write-Information "Checking for significant changes..." -Tags "Invoke-CIPipeline" + $versionInfo = Get-VersionInfoFromGit -CommitHash $BuildConfiguration.ReleaseHash + + if ($versionInfo.Data.VersionIncrement -eq "skip") { + Write-Information "Skipping release: $($versionInfo.Data.IncrementReason)" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $true + Error = "" + Data = [PSCustomObject]@{ + Version = $metadata.Data.Version + ReleaseHash = $metadata.Data.ReleaseHash + SkippedRelease = $true + SkipReason = $versionInfo.Data.IncrementReason + } + } + } + + Write-Information "Running build workflow..." -Tags "Invoke-CIPipeline" + $result = Invoke-BuildWorkflow -BuildConfiguration $BuildConfiguration + if (-not $result.Success) { + Write-Information "Build workflow failed: $($result.Error)" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $false + Error = "Build workflow failed: $($result.Error)" + StackTrace = $_.ScriptStackTrace + } + } + + Write-Information "Running release workflow..." -Tags "Invoke-CIPipeline" + $result = Invoke-ReleaseWorkflow -BuildConfiguration $BuildConfiguration + if (-not $result.Success) { + Write-Information "Release workflow failed: $($result.Error)" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $false + Error = "Release workflow failed: $($result.Error)" + StackTrace = $_.ScriptStackTrace + } + } + + Write-Information "CI/CD pipeline completed successfully" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $true + Version = $metadata.Data.Version + ReleaseHash = $metadata.Data.ReleaseHash + } + } + catch { + Write-Information "CI/CD pipeline failed: $_" -Tags "Invoke-CIPipeline" + return [PSCustomObject]@{ + Success = $false + Error = "CI/CD pipeline failed: $_" + StackTrace = $_.ScriptStackTrace + } + } +} + +#endregion + +# Export public functions +# Core build and environment functions +Export-ModuleMember -Function Initialize-BuildEnvironment, + Get-BuildConfiguration + +# Version management functions +Export-ModuleMember -Function Get-GitTags, + Get-VersionType, + Get-VersionInfoFromGit, + New-Version + +# Version comparison and conversion functions +Export-ModuleMember -Function ConvertTo-FourComponentVersion, + Get-VersionNotes + +# Metadata and documentation functions +Export-ModuleMember -Function New-Changelog, + Update-ProjectMetadata, + New-License + +# .NET SDK operations +Export-ModuleMember -Function Invoke-DotNetRestore, + Invoke-DotNetBuild, + Invoke-DotNetTest, + Invoke-DotNetPack, + Invoke-DotNetPublish + +# Release and publishing functions +Export-ModuleMember -Function Invoke-NuGetPublish, + New-GitHubRelease + +# Utility functions +Export-ModuleMember -Function Assert-LastExitCode, + Write-StepHeader, + Test-AnyFiles, + Get-GitLineEnding, + Set-GitIdentity, + Write-InformationStream, + Invoke-ExpressionWithLogging, + ConvertTo-ArraySafe + +# High-level workflow functions +Export-ModuleMember -Function Invoke-BuildWorkflow, + Invoke-ReleaseWorkflow, + Invoke-CIPipeline + +#region Module Variables +$script:DOTNET_VERSION = '9.0' +$script:LICENSE_TEMPLATE = Join-Path $PSScriptRoot "LICENSE.template" + +# Set PowerShell preferences +$ErrorActionPreference = 'Stop' +$WarningPreference = 'Stop' +$InformationPreference = 'Continue' +$DebugPreference = 'Ignore' +$VerbosePreference = 'Ignore' +$ProgressPreference = 'Ignore' + +# Get the line ending for the current system +$script:lineEnding = Get-GitLineEnding +#endregion \ No newline at end of file From efd5b18b9ced5c1d464eafb19f68d7826187e99a Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 22 Jul 2025 21:02:48 +1000 Subject: [PATCH 4/7] Code style --- CrossRepoActions/Dotnet.cs | 30 ++++++++++++------------ CrossRepoActions/Git.cs | 12 ++-------- CrossRepoActions/Verbs/BuildAndTest.cs | 10 ++++---- CrossRepoActions/Verbs/GitPull.cs | 2 +- CrossRepoActions/Verbs/Menu.cs | 2 +- CrossRepoActions/Verbs/UpdatePackages.cs | 6 ++--- 6 files changed, 27 insertions(+), 35 deletions(-) diff --git a/CrossRepoActions/Dotnet.cs b/CrossRepoActions/Dotnet.cs index f4f3c8c..6aadcf2 100644 --- a/CrossRepoActions/Dotnet.cs +++ b/CrossRepoActions/Dotnet.cs @@ -105,9 +105,9 @@ internal static Collection GetOutdatedProjectDependencies(AbsoluteFileP var projects = rootObject["projects"]?.AsArray() ?? throw new InvalidDataException(packageJsonError); - IEnumerable frameworks = projects.Where(p => + var frameworks = projects.Where(p => { - JsonObject? pObj = p?.AsObject(); + var pObj = p?.AsObject(); return pObj?["frameworks"]?.AsArray() != null; }) .SelectMany(p => @@ -116,7 +116,7 @@ internal static Collection GetOutdatedProjectDependencies(AbsoluteFileP ?? throw new InvalidDataException(packageJsonError); }); - Collection packages = frameworks.SelectMany(f => + var packages = frameworks.SelectMany(f => { return (f as JsonObject)?["topLevelPackages"]?.AsArray() ?? throw new InvalidDataException(packageJsonError); @@ -146,7 +146,7 @@ private static Package ExtractPackageFromJsonNode(JsonNode? p) internal static Collection UpdatePackages(AbsoluteFilePath projectFile, IEnumerable packages) { Collection output = []; - foreach (Package package in packages) + foreach (var package in packages) { Collection results = []; string pre = package.Version.Contains('-') ? "--prerelease" : ""; @@ -209,7 +209,7 @@ internal static Collection GetErrors(IEnumerable strings) => private static object ConsoleLock { get; } = new(); internal static Collection DiscoverSolutionDependencies(IEnumerable solutionFiles) { - Collection solutionFileCollection = solutionFiles.ToCollection(); + var solutionFileCollection = solutionFiles.ToCollection(); ConcurrentBag solutions = []; ProgressBar progressBar = new(); @@ -222,16 +222,16 @@ internal static Collection DiscoverSolutionDependencies(IEnumerable { - Collection projects = GetProjects(solutionFile) + var projects = GetProjects(solutionFile) .Select(p => solutionFile.DirectoryPath / p.As()) .ToCollection(); - Collection packages = projects + var packages = projects .Where(p => IsProjectPackable(p)) .Select(p => GetProjectPackage(p)) .ToCollection(); - Collection dependencies = GetSolutionDependencies(solutionFile); + var dependencies = GetSolutionDependencies(solutionFile); Solution solution = new() { @@ -258,20 +258,20 @@ internal static Collection DiscoverSolutionDependencies(IEnumerable SortSolutionsByDependencies(ICollection solutions) { - Collection unsatisfiedSolutions = solutions.ToCollection(); + var unsatisfiedSolutions = solutions.ToCollection(); Collection sortedSolutions = []; while (unsatisfiedSolutions.Count != 0) { - Collection unsatisfiedPackages = unsatisfiedSolutions + var unsatisfiedPackages = unsatisfiedSolutions .SelectMany(s => s.Packages) .ToCollection(); - Collection satisfied = unsatisfiedSolutions + var satisfied = unsatisfiedSolutions .Where(s => !s.Dependencies.IntersectBy(unsatisfiedPackages.Select(p => p.Name), p => p.Name).Any()) .ToCollection(); - foreach (Solution? solution in satisfied) + foreach (var solution in satisfied) { unsatisfiedSolutions.Remove(solution); sortedSolutions.Add(solution); @@ -291,7 +291,7 @@ internal static Collection DiscoverSolutionFiles(AbsoluteDirec internal static Collection DiscoverSolutions(AbsoluteDirectoryPath root) { - PersistentState persistentState = PersistentState.Get(); + var persistentState = PersistentState.Get(); if (persistentState.CachedSolutions.Count > 0) { return persistentState.CachedSolutions; @@ -307,8 +307,8 @@ internal static Collection DiscoverSolutions(AbsoluteDirectoryPath roo internal static bool IsSolutionNested(AbsoluteFilePath solutionPath) { - AbsoluteDirectoryPath solutionDir = solutionPath.DirectoryPath; - AbsoluteDirectoryPath checkDir = solutionDir; + var solutionDir = solutionPath.DirectoryPath; + var checkDir = solutionDir; do { checkDir = checkDir.Parent; diff --git a/CrossRepoActions/Git.cs b/CrossRepoActions/Git.cs index c8f7dd5..f0e956f 100644 --- a/CrossRepoActions/Git.cs +++ b/CrossRepoActions/Git.cs @@ -12,7 +12,7 @@ internal static class Git { internal static IEnumerable DiscoverRepositories(AbsoluteDirectoryPath root) { - PersistentState persistentState = PersistentState.Get(); + var persistentState = PersistentState.Get(); if (persistentState.CachedRepos.Count > 0) { return persistentState.CachedRepos; @@ -31,15 +31,7 @@ internal static IEnumerable DiscoverRepositories(Absolute internal static IEnumerable Pull(AbsoluteDirectoryPath repo) { - using var ps = PowerShell.Create(); - var results = ps - .AddCommand("git") - .AddArgument("-C") - .AddArgument(repo.ToString()) - .AddArgument("pull") - .AddArgument("--all") - .AddArgument("-v") - .InvokeAndReturnOutput(PowershellStreams.All); + Collection results = []; RunCommand.Execute($"git -C {repo} pull --all -v", new LineOutputHandler(s => results.Add(s.Trim()), s => results.Add(s.Trim()))); diff --git a/CrossRepoActions/Verbs/BuildAndTest.cs b/CrossRepoActions/Verbs/BuildAndTest.cs index c1ab6c9..c0ad691 100644 --- a/CrossRepoActions/Verbs/BuildAndTest.cs +++ b/CrossRepoActions/Verbs/BuildAndTest.cs @@ -20,10 +20,10 @@ private enum Status internal override void Run(BuildAndTest options) { - Collection solutions = Dotnet.DiscoverSolutions(options.Path); + var solutions = Dotnet.DiscoverSolutions(options.Path); Collection errorSummary = []; - foreach (Solution solution in solutions) + foreach (var solution in solutions) { string cwd = Directory.GetCurrentDirectory(); Directory.SetCurrentDirectory(solution.Path.DirectoryPath); @@ -34,9 +34,9 @@ internal override void Run(BuildAndTest options) Collection projectStatuses = []; Collection projectErrors = []; - foreach (AbsoluteFilePath project in solution.Projects) + foreach (var project in solution.Projects) { - Collection results = Dotnet.BuildProject(project); + var results = Dotnet.BuildProject(project); solutionErrors.AddMany(results); if (results.Count != 0) { @@ -69,7 +69,7 @@ internal override void Run(BuildAndTest options) continue; } - Collection testOutput = Dotnet.RunTests(); + var testOutput = Dotnet.RunTests(); testOutput = testOutput .Where(l => l.EndsWithOrdinal("]") && (l.Contains("Passed") || l.Contains("Failed"))) .Select(s => diff --git a/CrossRepoActions/Verbs/GitPull.cs b/CrossRepoActions/Verbs/GitPull.cs index 302f9f7..2d68d12 100644 --- a/CrossRepoActions/Verbs/GitPull.cs +++ b/CrossRepoActions/Verbs/GitPull.cs @@ -12,7 +12,7 @@ internal class GitPull : BaseVerb internal override void Run(GitPull options) { ConcurrentBag errorSummary = []; - IEnumerable repos = Git.DiscoverRepositories(options.Path); + var repos = Git.DiscoverRepositories(options.Path); _ = Parallel.ForEach(repos, new() { MaxDegreeOfParallelism = Program.MaxParallelism, diff --git a/CrossRepoActions/Verbs/Menu.cs b/CrossRepoActions/Verbs/Menu.cs index abfb69e..0ebaefa 100644 --- a/CrossRepoActions/Verbs/Menu.cs +++ b/CrossRepoActions/Verbs/Menu.cs @@ -40,7 +40,7 @@ internal override void Run(Menu options) private static LabelMenuItem CreateMenuItem(Type verbType) { - BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb; + var verb = Activator.CreateInstance(verbType) as BaseVerb; Debug.Assert(verb != null); return new LabelMenuItem() { diff --git a/CrossRepoActions/Verbs/UpdatePackages.cs b/CrossRepoActions/Verbs/UpdatePackages.cs index 42d3ad2..96ee3c9 100644 --- a/CrossRepoActions/Verbs/UpdatePackages.cs +++ b/CrossRepoActions/Verbs/UpdatePackages.cs @@ -17,7 +17,7 @@ internal override void Run(UpdatePackages options) while (true) { ConcurrentBag errorSummary = []; - Collection solutions = Dotnet.DiscoverSolutions(options.Path); + var solutions = Dotnet.DiscoverSolutions(options.Path); _ = Parallel.ForEach(solutions, new() { @@ -27,7 +27,7 @@ internal override void Run(UpdatePackages options) { try { - foreach (StrongPaths.AbsoluteFilePath project in solution.Projects) + foreach (var project in solution.Projects) { var solutionDir = solution.Path.DirectoryPath; bool isProjectFileModified = Git.Status(solutionDir, project).Any(); @@ -40,7 +40,7 @@ internal override void Run(UpdatePackages options) var errorLines = new Collection(); foreach (var package in outdatedPackages) { - IEnumerable packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase) && !s.Contains("imported file", StringComparison.InvariantCultureIgnoreCase)); + var packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase) && !s.Contains("imported file", StringComparison.InvariantCultureIgnoreCase)); if (packageErrors.Any()) { errorLines.AddMany(packageErrors); From 9643d817d4b4592933c87d974543ab5a073cbd0a Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 22 Jul 2025 21:06:02 +1000 Subject: [PATCH 5/7] Enhance null safety checks in Dotnet class filters --- CrossRepoActions/Dotnet.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CrossRepoActions/Dotnet.cs b/CrossRepoActions/Dotnet.cs index 6aadcf2..61adfb7 100644 --- a/CrossRepoActions/Dotnet.cs +++ b/CrossRepoActions/Dotnet.cs @@ -47,7 +47,7 @@ internal static Collection GetTests() RunCommand.Execute($"dotnet vstest --ListTests --nologo **/bin/**/*Test.dll", new LineOutputHandler(results.Add, results.Add)); var filteredResults = results - .Where(r => !r.StartsWith("The following") && !r.StartsWith("No test source")) + .Where(r => r is not null && !r.StartsWith("The following") && !r.StartsWith("No test source")) .ToCollection(); return filteredResults; @@ -60,7 +60,7 @@ internal static Collection GetProjects(AbsoluteFilePath solutionFile) RunCommand.Execute($"dotnet sln {solutionFile} list", new LineOutputHandler(results.Add, results.Add)); var filteredResults = results - .Where(r => r.EndsWithOrdinal(".csproj")) + .Where(r => r is not null && r.EndsWithOrdinal(".csproj")) .ToCollection(); return filteredResults; @@ -73,7 +73,7 @@ internal static Collection GetSolutionDependencies(AbsoluteFilePath sol RunCommand.Execute($"dotnet list {solutionFile} package --include-transitive", new LineOutputHandler(results.Add, results.Add)); var filteredResults = results - .Where(r => r.StartsWithOrdinal(">")) + .Where(r => r is not null && r.StartsWithOrdinal(">")) .ToCollection(); var dependencies = filteredResults @@ -202,7 +202,7 @@ internal static Package GetProjectPackage(AbsoluteFilePath projectFile) } internal static Collection GetErrors(IEnumerable strings) => - strings.Where(r => (r.Contains("error") || r.Contains("failed")) + strings.Where(r => r is not null && (r.Contains("error") || r.Contains("failed")) && !(r.Contains("passed") || r.Contains("0 Error"))) .ToCollection(); From 40bfbc7bd71988b6270271ba3ff7eb72a2697347 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 22 Jul 2025 21:59:35 +1000 Subject: [PATCH 6/7] Update package references and enhance package management functionality - Updated package versions in CrossRepoActions.csproj for ktsu libraries and Microsoft packages. - Added support for central package management in Dotnet class, including methods to check for central management, update packages, and handle outdated dependencies. - Refactored UpdatePackages verb to accommodate both central and traditional package management approaches. --- CrossRepoActions/CrossRepoActions.csproj | 14 +- CrossRepoActions/Dotnet.cs | 370 +++++++++++++++++++++++ CrossRepoActions/Verbs/UpdatePackages.cs | 197 ++++++++---- 3 files changed, 517 insertions(+), 64 deletions(-) diff --git a/CrossRepoActions/CrossRepoActions.csproj b/CrossRepoActions/CrossRepoActions.csproj index ef04dce..534d247 100644 --- a/CrossRepoActions/CrossRepoActions.csproj +++ b/CrossRepoActions/CrossRepoActions.csproj @@ -8,13 +8,13 @@ - - - - - - - + + + + + + + diff --git a/CrossRepoActions/Dotnet.cs b/CrossRepoActions/Dotnet.cs index 61adfb7..5cc9584 100644 --- a/CrossRepoActions/Dotnet.cs +++ b/CrossRepoActions/Dotnet.cs @@ -3,7 +3,10 @@ namespace ktsu.CrossRepoActions; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Xml; +using System.Xml.Linq; using DustInTheWind.ConsoleTools.Controls.Spinners; @@ -144,6 +147,23 @@ private static Package ExtractPackageFromJsonNode(JsonNode? p) } internal static Collection UpdatePackages(AbsoluteFilePath projectFile, IEnumerable packages) + { + // Find the solution file to determine if central package management is used + var solutionPath = FindSolutionForProject(projectFile); + if (solutionPath != null && UsesCentralPackageManagement(solutionPath)) + { + // Use central package management update approach + return UpdatePackagesWithCentralManagement(solutionPath, packages); + } + + // Use traditional per-project update approach + return UpdatePackagesTraditional(projectFile, packages); + } + + /// + /// Updates packages using traditional per-project approach. + /// + private static Collection UpdatePackagesTraditional(AbsoluteFilePath projectFile, IEnumerable packages) { Collection output = []; foreach (var package in packages) @@ -158,6 +178,49 @@ internal static Collection UpdatePackages(AbsoluteFilePath projectFile, return output; } + /// + /// Updates packages using central package management. + /// + internal static Collection UpdatePackagesWithCentralManagement(AbsoluteFilePath solutionPath, IEnumerable packages) + { + var directoryPackagesPath = GetDirectoryPackagesPath(solutionPath); + if (directoryPackagesPath == null) + { + Collection errorResult = []; + errorResult.Add("Error: Central package management is enabled but Directory.Packages.props not found"); + return errorResult; + } + + return UpdateCentralPackageVersions(directoryPackagesPath, packages); + } + + /// + /// Finds the solution file that contains the given project file. + /// + /// Path to the project file + /// Path to the solution file or null if not found + private static AbsoluteFilePath? FindSolutionForProject(AbsoluteFilePath projectFile) + { + var directory = projectFile.DirectoryPath; + + // Search upward from project directory for solution files + var currentDir = directory; + while (!string.IsNullOrEmpty(currentDir) && currentDir != currentDir.Parent) + { + var solutionFiles = Directory.EnumerateFiles(currentDir, "*.sln", SearchOption.TopDirectoryOnly); + string? solutionFile = solutionFiles.FirstOrDefault(); + + if (!string.IsNullOrEmpty(solutionFile)) + { + return solutionFile.As(); + } + + currentDir = currentDir.Parent; + } + + return null; + } + internal static string GetProjectAssemblyName(AbsoluteFilePath projectFile) { Collection results = []; @@ -321,4 +384,311 @@ internal static bool IsSolutionNested(AbsoluteFilePath solutionPath) return false; } + + /// + /// Checks if a solution/repository uses central package management. + /// + /// Path to the solution file + /// True if central package management is enabled + internal static bool UsesCentralPackageManagement(AbsoluteFilePath solutionPath) + { + var solutionDir = solutionPath.DirectoryPath; + + // Check for Directory.Packages.props file + var directoryPackagesPath = solutionDir / "Directory.Packages.props".As(); + if (File.Exists(directoryPackagesPath)) + { + return true; + } + + // Check for ManagePackageVersionsCentrally property in Directory.Build.props + var directoryBuildPropsPath = solutionDir / "Directory.Build.props".As(); + if (File.Exists(directoryBuildPropsPath)) + { + try + { + var doc = XDocument.Load(directoryBuildPropsPath); + return doc.Descendants("ManagePackageVersionsCentrally") + .Any(e => string.Equals(e.Value?.Trim(), "true", StringComparison.OrdinalIgnoreCase)); + } + catch (Exception ex) when (ex is XmlException or IOException) + { + // If we can't parse the file, assume no central package management + return false; + } + } + + return false; + } + + /// + /// Gets the path to the Directory.Packages.props file for a solution. + /// + /// Path to the solution file + /// Path to Directory.Packages.props or null if not found + internal static AbsoluteFilePath? GetDirectoryPackagesPath(AbsoluteFilePath solutionPath) + { + var solutionDir = solutionPath.DirectoryPath; + var directoryPackagesPath = solutionDir / "Directory.Packages.props".As(); + + return File.Exists(directoryPackagesPath) ? directoryPackagesPath : null; + } + + /// + /// Gets package versions from Directory.Packages.props file. + /// + /// Path to Directory.Packages.props + /// Collection of packages with their centrally managed versions + internal static Collection GetCentralPackageVersions(AbsoluteFilePath directoryPackagesPath) + { + if (!File.Exists(directoryPackagesPath)) + { + return []; + } + + try + { + var doc = XDocument.Load(directoryPackagesPath); + var packages = doc.Descendants("PackageVersion") + .Where(e => e.Attribute("Include") != null && e.Attribute("Version") != null) + .Select(e => new Package + { + Name = e.Attribute("Include")!.Value, + Version = e.Attribute("Version")!.Value + }) + .ToCollection(); + + return packages; + } + catch (Exception ex) when (ex is XmlException or IOException) + { + Console.WriteLine($"Warning: Could not parse Directory.Packages.props: {ex.Message}"); + return []; + } + } + + /// + /// Updates package versions in Directory.Packages.props file. + /// + /// Path to Directory.Packages.props + /// Packages to update + /// Collection of result messages + internal static Collection UpdateCentralPackageVersions(AbsoluteFilePath directoryPackagesPath, IEnumerable packages) + { + Collection results = []; + + if (!File.Exists(directoryPackagesPath)) + { + results.Add($"Error: Directory.Packages.props not found at {directoryPackagesPath}"); + return results; + } + + try + { + var doc = XDocument.Load(directoryPackagesPath); + bool modified = false; + + foreach (var package in packages) + { + var packageVersionElement = doc.Descendants("PackageVersion") + .FirstOrDefault(e => string.Equals(e.Attribute("Include")?.Value, package.Name, StringComparison.OrdinalIgnoreCase)); + + if (packageVersionElement != null) + { + string? currentVersion = packageVersionElement.Attribute("Version")?.Value; + if (currentVersion != package.Version) + { + packageVersionElement.SetAttributeValue("Version", package.Version); + results.Add($"Updated {package.Name} from {currentVersion} to {package.Version} in Directory.Packages.props"); + modified = true; + } + else + { + results.Add($"{package.Name} version {package.Version} is already up to date in Directory.Packages.props"); + } + } + else + { + // Add new package version if it doesn't exist + var itemGroup = doc.Descendants("ItemGroup").FirstOrDefault(); + if (itemGroup == null) + { + // Create ItemGroup if it doesn't exist + var project = doc.Element("Project"); + if (project != null) + { + itemGroup = new XElement("ItemGroup"); + project.Add(itemGroup); + } + } + + if (itemGroup != null) + { + var newPackageVersion = new XElement("PackageVersion"); + newPackageVersion.SetAttributeValue("Include", package.Name); + newPackageVersion.SetAttributeValue("Version", package.Version); + itemGroup.Add(newPackageVersion); + results.Add($"Added {package.Name} version {package.Version} to Directory.Packages.props"); + modified = true; + } + else + { + results.Add($"Error: Could not add {package.Name} to Directory.Packages.props - no Project element found"); + } + } + } + + if (modified) + { + doc.Save(directoryPackagesPath); + results.Add($"Saved changes to Directory.Packages.props"); + } + } + catch (Exception ex) when (ex is XmlException or IOException) + { + results.Add($"Error updating Directory.Packages.props: {ex.Message}"); + } + + return results; + } + + /// + /// Gets outdated package dependencies for projects using central package management. + /// + /// Path to the solution file + /// Collection of outdated packages + internal static Collection GetOutdatedCentralPackageDependencies(AbsoluteFilePath solutionPath) + { + var directoryPackagesPath = GetDirectoryPackagesPath(solutionPath); + if (directoryPackagesPath == null) + { + return []; + } + + // Get current central package versions + var centralPackages = GetCentralPackageVersions(directoryPackagesPath); + + // Get outdated packages from dotnet CLI + string outdatedPackagesJson = GetOutdatedPackagesJson(solutionPath); + if (string.IsNullOrEmpty(outdatedPackagesJson)) + { + return []; + } + + // Parse and filter outdated packages + return ParseOutdatedPackages(outdatedPackagesJson, centralPackages); + } + + /// + /// Gets the JSON output from dotnet list package --outdated command. + /// + private static string GetOutdatedPackagesJson(AbsoluteFilePath solutionPath) + { + Collection results = []; + RunCommand.Execute($"dotnet list {solutionPath} package --outdated --format=json", new LineOutputHandler(results.Add, results.Add)); + return string.Join("", results); + } + + /// + /// Parses outdated packages JSON and filters for centrally managed packages. + /// + private static Collection ParseOutdatedPackages(string jsonString, Collection centralPackages) + { + try + { + var rootObject = JsonNode.Parse(jsonString)?.AsObject(); + if (rootObject == null) + { + return []; + } + + var projects = rootObject["projects"]?.AsArray(); + if (projects == null) + { + return []; + } + + var outdatedPackages = new Dictionary(); + + foreach (var project in projects) + { + ProcessProjectForOutdatedPackages(project, centralPackages, outdatedPackages); + } + + return outdatedPackages.Values.ToCollection(); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + Console.WriteLine($"Warning: Could not parse outdated packages JSON: {ex.Message}"); + return []; + } + } + + /// + /// Processes a single project node to find outdated packages. + /// + private static void ProcessProjectForOutdatedPackages(JsonNode? project, Collection centralPackages, Dictionary outdatedPackages) + { + var frameworks = project?.AsObject()?["frameworks"]?.AsArray(); + if (frameworks == null) + { + return; + } + + foreach (var framework in frameworks) + { + ProcessFrameworkForOutdatedPackages(framework, centralPackages, outdatedPackages); + } + } + + /// + /// Processes a single framework node to find outdated packages. + /// + private static void ProcessFrameworkForOutdatedPackages(JsonNode? framework, Collection centralPackages, Dictionary outdatedPackages) + { + var topLevelPackages = framework?.AsObject()?["topLevelPackages"]?.AsArray(); + if (topLevelPackages == null) + { + return; + } + + foreach (var packageNode in topLevelPackages) + { + ProcessPackageNodeForOutdated(packageNode, centralPackages, outdatedPackages); + } + } + + /// + /// Processes a single package node to check if it's outdated and centrally managed. + /// + private static void ProcessPackageNodeForOutdated(JsonNode? packageNode, Collection centralPackages, Dictionary outdatedPackages) + { + var packageObj = packageNode?.AsObject(); + if (packageObj == null) + { + return; + } + + string? id = packageObj["id"]?.AsValue().GetValue(); + string? latestVersion = packageObj["latestVersion"]?.AsValue().GetValue(); + + if (IsPackageOutdatedAndCentrallyManaged(id, latestVersion, centralPackages)) + { + outdatedPackages[id!] = new Package { Name = id!, Version = latestVersion! }; + } + } + + /// + /// Checks if a package is outdated and centrally managed. + /// + private static bool IsPackageOutdatedAndCentrallyManaged(string? id, string? latestVersion, Collection centralPackages) + { + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(latestVersion)) + { + return false; + } + + var centralPackage = centralPackages.FirstOrDefault(p => string.Equals(p.Name, id, StringComparison.OrdinalIgnoreCase)); + return centralPackage != null && centralPackage.Version != latestVersion; + } } diff --git a/CrossRepoActions/Verbs/UpdatePackages.cs b/CrossRepoActions/Verbs/UpdatePackages.cs index 96ee3c9..652e853 100644 --- a/CrossRepoActions/Verbs/UpdatePackages.cs +++ b/CrossRepoActions/Verbs/UpdatePackages.cs @@ -6,6 +6,7 @@ namespace ktsu.CrossRepoActions.Verbs; using CommandLine; using ktsu.Extensions; +using ktsu.StrongPaths; [Verb("UpdatePackages")] internal class UpdatePackages : BaseVerb @@ -27,68 +28,34 @@ internal override void Run(UpdatePackages options) { try { - foreach (var project in solution.Projects) + // Check if this solution uses central package management + bool usesCentralPackageManagement = Dotnet.UsesCentralPackageManagement(solution.Path); + var solutionDir = solution.Path.DirectoryPath; + + if (usesCentralPackageManagement) { - var solutionDir = solution.Path.DirectoryPath; - bool isProjectFileModified = Git.Status(solutionDir, project).Any(); - bool canCommit = !isProjectFileModified; - var outdatedPackages = Dotnet.GetOutdatedProjectDependencies(project); - var results = Dotnet.UpdatePackages(project, outdatedPackages); - var upToDate = new Collection(); - var updated = new Collection(); - var errored = new Collection(); - var errorLines = new Collection(); - foreach (var package in outdatedPackages) + // Handle central package management at solution level + var outdatedPackages = Dotnet.GetOutdatedCentralPackageDependencies(solution.Path); + if (outdatedPackages.Count > 0) { - var packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase) && !s.Contains("imported file", StringComparison.InvariantCultureIgnoreCase)); - if (packageErrors.Any()) - { - errorLines.AddMany(packageErrors); - errored.Add(package); - continue; - } - - bool isUpToDate = results.Any(s => s.Contains($"'{package.Name}' version '{package.Version}' updated", StringComparison.InvariantCultureIgnoreCase)); - if (isUpToDate) - { - upToDate.Add(package); - continue; - } - - bool wasUpdated = results.Any(s => s.Contains($"'{package.Name}' version", StringComparison.InvariantCultureIgnoreCase) && s.Contains("updated in file", StringComparison.InvariantCultureIgnoreCase) && !s.Contains($"version '{package.Version}'", StringComparison.InvariantCultureIgnoreCase)); - if (wasUpdated) - { - updated.Add(package); - continue; - } - } + var directoryPackagesPath = Dotnet.GetDirectoryPackagesPath(solution.Path); + bool isDirectoryPackagesModified = directoryPackagesPath != null && Git.Status(solutionDir, directoryPackagesPath).Any(); + bool canCommit = !isDirectoryPackagesModified; - string projectStatus = $"✅ {project.FileName}"; - if (errored.Count != 0) - { - string error = $"❌ {project.FileName}"; - errorSummary.Add(error); - projectStatus = error; + var results = Dotnet.UpdatePackagesWithCentralManagement(solution.Path, outdatedPackages); + ProcessCentralPackageResults(solution, results, outdatedPackages, canCommit, solutionDir, directoryPackagesPath, errorSummary); } - else if (updated.Count != 0) - { - projectStatus = $"🚀 {project.FileName}"; - if (canCommit) - { - Git.Unstage(solutionDir); - Git.Pull(solutionDir); - Git.Add(solutionDir, project); - Git.Commit(solutionDir, $"Updated packages in {project.FileName}"); - Git.Push(solutionDir); - } - } - - lock (ConsoleLock) + } + else + { + // Handle traditional per-project package management + foreach (var project in solution.Projects) { - Console.WriteLine(projectStatus); - upToDate.Select(p => $"\t✅ {p.Name}").WriteItemsToConsole(); - updated.Select(p => $"\t🚀 {p.Name}").WriteItemsToConsole(); - errored.Select(p => $"\t❌ {p.Name}").WriteItemsToConsole(); + bool isProjectFileModified = Git.Status(solutionDir, project).Any(); + bool canCommit = !isProjectFileModified; + var outdatedPackages = Dotnet.GetOutdatedProjectDependencies(project); + var results = Dotnet.UpdatePackages(project, outdatedPackages); + ProcessProjectPackageResults(project, results, outdatedPackages, canCommit, solutionDir, errorSummary); } } } @@ -115,4 +82,120 @@ internal override void Run(UpdatePackages options) //Thread.Sleep(1000 * 60 * 5); } } + + private static void ProcessCentralPackageResults(Solution solution, Collection results, Collection outdatedPackages, bool canCommit, AbsoluteDirectoryPath solutionDir, AbsoluteFilePath? directoryPackagesPath, ConcurrentBag errorSummary) + { + var upToDate = new Collection(); + var updated = new Collection(); + var errored = new Collection(); + + foreach (var package in outdatedPackages) + { + var packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase)); + if (packageErrors.Any()) + { + errored.Add(package); + continue; + } + + bool wasUpdated = results.Any(s => s.Contains($"Updated {package.Name}")); + if (wasUpdated) + { + updated.Add(package); + } + else + { + upToDate.Add(package); + } + } + + string solutionStatus = $"✅ {solution.Name} (Central Package Management)"; + if (errored.Count != 0) + { + string error = $"❌ {solution.Name} (Central Package Management)"; + errorSummary.Add(error); + solutionStatus = error; + } + else if (updated.Count != 0) + { + solutionStatus = $"🚀 {solution.Name} (Central Package Management)"; + if (canCommit && directoryPackagesPath != null) + { + Git.Unstage(solutionDir); + Git.Pull(solutionDir); + Git.Add(solutionDir, directoryPackagesPath); + Git.Commit(solutionDir, $"Updated central package versions in {directoryPackagesPath.FileName}"); + Git.Push(solutionDir); + } + } + + lock (ConsoleLock) + { + Console.WriteLine(solutionStatus); + upToDate.Select(p => $"\t✅ {p.Name}").WriteItemsToConsole(); + updated.Select(p => $"\t🚀 {p.Name}").WriteItemsToConsole(); + errored.Select(p => $"\t❌ {p.Name}").WriteItemsToConsole(); + } + } + + private static void ProcessProjectPackageResults(AbsoluteFilePath project, Collection results, Collection outdatedPackages, bool canCommit, AbsoluteDirectoryPath solutionDir, ConcurrentBag errorSummary) + { + var upToDate = new Collection(); + var updated = new Collection(); + var errored = new Collection(); + var errorLines = new Collection(); + + foreach (var package in outdatedPackages) + { + var packageErrors = results.Where(s => s.Contains($"{package.Name}") && s.Contains("error", StringComparison.InvariantCultureIgnoreCase) && !s.Contains("imported file", StringComparison.InvariantCultureIgnoreCase)); + if (packageErrors.Any()) + { + errorLines.AddMany(packageErrors); + errored.Add(package); + continue; + } + + bool isUpToDate = results.Any(s => s.Contains($"'{package.Name}' version '{package.Version}' updated", StringComparison.InvariantCultureIgnoreCase)); + if (isUpToDate) + { + upToDate.Add(package); + continue; + } + + bool wasUpdated = results.Any(s => s.Contains($"'{package.Name}' version", StringComparison.InvariantCultureIgnoreCase) && s.Contains("updated in file", StringComparison.InvariantCultureIgnoreCase) && !s.Contains($"version '{package.Version}'", StringComparison.InvariantCultureIgnoreCase)); + if (wasUpdated) + { + updated.Add(package); + continue; + } + } + + string projectStatus = $"✅ {project.FileName}"; + if (errored.Count != 0) + { + string error = $"❌ {project.FileName}"; + errorSummary.Add(error); + projectStatus = error; + } + else if (updated.Count != 0) + { + projectStatus = $"🚀 {project.FileName}"; + if (canCommit) + { + Git.Unstage(solutionDir); + Git.Pull(solutionDir); + Git.Add(solutionDir, project); + Git.Commit(solutionDir, $"Updated packages in {project.FileName}"); + Git.Push(solutionDir); + } + } + + lock (ConsoleLock) + { + Console.WriteLine(projectStatus); + upToDate.Select(p => $"\t✅ {p.Name}").WriteItemsToConsole(); + updated.Select(p => $"\t🚀 {p.Name}").WriteItemsToConsole(); + errored.Select(p => $"\t❌ {p.Name}").WriteItemsToConsole(); + } + } } From 5da887051d3e57debd216d10ceccaea0688808af Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 23 Jul 2025 13:21:12 +1000 Subject: [PATCH 7/7] Add detailed README documentation for CrossRepoActions - Introduced comprehensive documentation outlining the features, installation instructions, usage examples, and command options for the CrossRepoActions .NET console application. - Highlighted core commands such as UpdatePackages, BuildAndTest, and GitPull, along with their functionalities. - Included prerequisites, configuration options, and dependencies to assist users in setting up and utilizing the application effectively. --- README.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/README.md b/README.md index a2441aa..3e40d7f 100644 --- a/README.md +++ b/README.md @@ -1 +1,192 @@ # CrossRepoActions + +CrossRepoActions is a powerful .NET console application designed to perform batch operations across multiple repositories and solutions. It streamlines common development tasks by automating actions across your entire development workspace. + +## Features + +### 🚀 Core Commands + +- **UpdatePackages** - Automatically update NuGet packages across multiple solutions with support for both traditional and central package management +- **BuildAndTest** - Build and test multiple solutions in parallel with detailed status reporting +- **GitPull** - Pull changes from multiple Git repositories simultaneously +- **Menu** - Interactive menu-driven interface for easy command selection +- **DiscoverRepositories** - Discover all Git repositories in a directory tree +- **DiscoverSolutions** - Discover all .NET solutions in a directory tree + +### ✨ Key Benefits + +- **Parallel Processing** - Leverages multi-threading for fast execution across multiple repositories +- **Central Package Management Support** - Full support for .NET's central package management features +- **Smart Error Handling** - Comprehensive error reporting and recovery +- **Interactive Interface** - Menu-driven interface for ease of use +- **Flexible Path Configuration** - Configurable root paths for repository discovery + +## Installation + +### Prerequisites + +- .NET 9.0 or later +- Git (for repository operations) +- PowerShell (for certain operations) + +### Building from Source + +1. Clone the repository: + ```bash + git clone https://github.com/ktsu-dev/CrossRepoActions.git + cd CrossRepoActions + ``` + +2. Build the application: + ```bash + dotnet build + ``` + +3. Run the application: + ```bash + dotnet run --project CrossRepoActions + ``` + +## Usage + +### Interactive Menu (Default) + +Simply run the application without arguments to access the interactive menu: + +```bash +CrossRepoActions +``` + +or explicitly: + +```bash +CrossRepoActions Menu +``` + +### Command Line Interface + +#### Update Packages Across Repositories + +```bash +CrossRepoActions UpdatePackages --path "C:\dev\my-projects" +``` + +This command will: +- Discover all .NET solutions in the specified path +- Check for outdated NuGet packages +- Update packages automatically +- Handle both traditional and central package management scenarios +- Commit changes to Git when appropriate + +#### Build and Test Multiple Solutions + +```bash +CrossRepoActions BuildAndTest --path "C:\dev\my-projects" +``` + +This command will: +- Build all discovered solutions +- Run tests for each project +- Provide detailed status reporting +- Generate error summaries for failed builds + +#### Pull Changes from Multiple Repositories + +```bash +CrossRepoActions GitPull --path "C:\dev\my-projects" +``` + +This command will: +- Discover all Git repositories in the specified path +- Execute `git pull` on each repository in parallel +- Report success/failure status for each repository +- Provide detailed error information for failed pulls + +#### Discover Repositories and Solutions + +```bash +# Discover all Git repositories +CrossRepoActions DiscoverRepositories --path "C:\dev\my-projects" + +# Discover all .NET solutions +CrossRepoActions DiscoverSolutions --path "C:\dev\my-projects" +``` + +### Configuration + +#### Default Path Configuration + +The application uses a default path of `c:/dev/ktsu-dev` for repository discovery. You can override this using the `--path` or `-p` option: + +```bash +CrossRepoActions UpdatePackages -p "C:\your\custom\path" +``` + +#### Persistent Settings + +CrossRepoActions maintains persistent settings using the `ktsu.AppDataStorage` library. Settings are automatically saved and restored between sessions. + +## Command Options + +### Global Options + +- `-p, --path` - The root path to discover solutions/repositories from (default: `c:/dev/ktsu-dev`) + +### Package Update Features + +- **Central Package Management**: Automatically detects and handles solutions using central package management +- **Smart Committing**: Only commits changes when the working directory is clean +- **Parallel Processing**: Updates multiple solutions simultaneously for improved performance +- **Detailed Reporting**: Provides comprehensive status updates and error reporting + +## Dependencies + +CrossRepoActions leverages several key libraries: + +- **CommandLineParser** (2.9.1) - Command-line argument parsing +- **ConsoleTools** (1.2.1) - Interactive console menus +- **ktsu.AppDataStorage** (1.15.6) - Persistent application settings +- **ktsu.Extensions** (1.5.6) - Utility extensions +- **ktsu.RunCommand** (1.3.1) - External command execution +- **Microsoft.PowerShell.SDK** (7.5.2) - PowerShell integration +- **NuGet.Versioning** (6.14.0) - NuGet package version handling + +## Development + +### Project Structure + +``` +CrossRepoActions/ +├── Verbs/ # Command implementations +│ ├── BaseVerb.cs # Base command class +│ ├── UpdatePackages.cs +│ ├── BuildAndTest.cs +│ ├── GitPull.cs +│ ├── Menu.cs +│ └── ... +├── Dotnet.cs # .NET CLI operations +├── Git.cs # Git operations +├── Package.cs # Package management +├── Solution.cs # Solution discovery +└── Program.cs # Application entry point +``` + +### Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md) for details. + +## Version + +Current version: 1.2.2-pre.3 + +--- + +**CrossRepoActions** - Streamlining multi-repository development workflows since 2023.