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()}{TargetName}>", 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
}
}
}