using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Xml.Linq; using Microsoft.Build.Framework; using Task = Microsoft.Build.Utilities.Task; namespace Keysight.OpenTap.Sdk.MSBuild { /// /// MSBuild Task to version the build output using gitversion. /// [Serializable] public class CalculateVersion : Task, ICancelableTask { private const string TargetName = "OpenTapSetAssemblyVersion"; /// /// The build directory containing 'tap.exe' and 'OpenTAP.dll' /// public string TapDir { get; set; } /// /// csproj file. This is needed because OpenTAP supports multiple gitversion files. /// Gitversion should be resolved from the directory containing the project file. /// public string SourceFile { get; set; } /// /// Optional input version. This is useful in CI pipelines where shallow clones /// are used for performance reasons. /// public string InputVersion { get; set; } /// /// The output shortversion (x.y.z). /// [Microsoft.Build.Framework.Output] public string OutputShortVersion { get; set; } /// /// The output gitversion plus informational version (x.y.z-beta.1+hash). /// [Microsoft.Build.Framework.Output] public string OutputLongVersion { get; set; } private bool tryParseXElement(string text, out XElement elem) { try { elem = XElement.Parse(text, LoadOptions.None); return true; } catch { elem = null; return false; } } private bool runProcess(string filename, string arguments, string workingDirectory, out string stdout, out string stderr) { var si = new ProcessStartInfo(filename, arguments) { RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, WorkingDirectory = workingDirectory, }; var errStream = new StringBuilder(); var outStream = new StringBuilder(); var proc = new Process(); proc.StartInfo = si; proc.OutputDataReceived += (_, data) => { if (!string.IsNullOrWhiteSpace(data?.Data)) outStream.Append(data.Data); }; proc.ErrorDataReceived += (_, data) => { if (!string.IsNullOrWhiteSpace(data?.Data)) errStream.Append(data.Data); }; proc.Start(); proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); proc.WaitForExit(10000); if (!proc.HasExited) { stdout = stderr = null; Log.LogError($"{TargetName}: tap sdk gitversion is hanging."); return false; } stdout = outStream.ToString(); stderr = errStream.ToString(); return proc.ExitCode == 0; } private bool isWindows() { switch (Environment.OSVersion.Platform) { case PlatformID.Win32NT: case PlatformID.Win32S: case PlatformID.Win32Windows: case PlatformID.WinCE: return true; default: return false; } } // Check if a file with the given name exists in any ancestor directory private bool fileIsAncestor(string name, DirectoryInfo root) { var comparer = isWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; while (root != null) { if (root.EnumerateFileSystemInfos().Any(i => i.Name.Equals(name, comparer))) return true; root = root.Parent; } return false; } // This does not need to be perfect. private static Regex versionRegex = new Regex(@"^\d+\.\d+\.\d+", RegexOptions.Compiled); private bool tryParseVersion(string inputVersion, out string shortVersion, out string longVersion) { inputVersion = inputVersion.Trim(); var m = versionRegex.Match(inputVersion); if (m.Success) { shortVersion = m.Value; longVersion = inputVersion; return true; } shortVersion = longVersion = null; return false; } private bool tryCalculateGitversion(out string shortVersion, out string gitversion) { shortVersion = null; gitversion = null; var workingDirectory = Path.GetDirectoryName(SourceFile); var dirInfo = new DirectoryInfo(workingDirectory); // Ensure this is a git repository if (!fileIsAncestor(".git", dirInfo)) { Log.LogError( $"{TargetName}: The project file '{SourceFile}' is not in a git directory. {TargetName} is only supported in git projects."); return false; } // And that it uses gitversioning if (!fileIsAncestor(".gitversion", dirInfo)) { Log.LogError( $"{TargetName}: This project does not have a .gitversion file. {TargetName} is only supported in gitversion projects.\n" + $"See https://doc.opentap.io/Developer%20Guide/Plugin%20Packaging%20and%20Versioning/Readme.html#git-assisted-versioning"); return false; } // Start a subprocess to get the gitversion. There are a couple of reasons we don't calculate it in-process: // 1. libgit uses a native dll which is annoying to load. OpenTAP already includes logic for this which // makes several assumptions that do not apply during dotnet build. // 2. GitVersionCalculator is internal, and I would prefer to not make it public. // For these reasons, it is much simpler to just start a process and parse the output. string tapName = isWindows() ? "tap.exe" : "tap"; var tap = Path.Combine(TapDir, tapName); if (!runProcess(tap, "sdk gitversion", workingDirectory, out var stdout, out var stderr)) { return false; } if (!string.IsNullOrWhiteSpace(stderr)) { // This could indicate a problem, but logging it as an error would fail the build. // Log it as a warning instead so the user is at least aware Log.LogWarning(stderr); } // There could potentially be multiple lines in the output due diagnostics or warnings from OpenTAP. // Find the line that looks like a gitversion var lines = stdout.Split('\n').Select(line => line.Trim()).ToArray(); foreach (var l in lines) { if (tryParseVersion(l, out shortVersion, out gitversion)) return true; } Log.LogError($"{TargetName}: Unable to parse gitversion from output:\n{stdout}"); return false; } public override bool Execute() { string shortVersion; string longVersion; string input; // parse input gitversion. It can have a couple of different formats: // 1. A flag, such as '1' or 'true' // 2. A semantic version // 3. An xml tag enclosing a value, such as 1.2.3 // Case 1: calculate the version if (new[] { "true", "1" , "gitversion", "auto", "git" }.Contains(InputVersion.Trim(), StringComparer.OrdinalIgnoreCase)) { if (tryCalculateGitversion(out shortVersion, out longVersion)) { OutputShortVersion = shortVersion; OutputLongVersion = longVersion; return true; } return false; } // Case 2/3: parse the provided version from the input if (!tryParseXElement($"<{TargetName}>{InputVersion.Trim()}", out var elem)) { // This should not be possible since the input is exactly the inner text of the .csproj property element. // If the input is not valid xml, then the compilation should have already failed. Log.LogError($"{TargetName}: Failed to parse input."); return false; } if (elem.Element("Version") is XElement gv) { input = gv.Value.Trim(); } else if (tryParseVersion(elem.Value.Trim(), out _, out _)) { input = elem.Value.Trim(); } else { Log.LogError($"{TargetName}: Expected element named 'Version'."); return false; } if (!tryParseVersion(input, out shortVersion, out longVersion)) { Log.LogError($"{TargetName}: Provided version is not a valid semantic version: '{input}'"); return false; } OutputShortVersion = shortVersion; OutputLongVersion = longVersion; return true; } public void Cancel() { // No cancel logic needed } } }