diff --git a/eng/Subsets.props b/eng/Subsets.props index 337bca6fa63caf..1d8f64a5ca9fb3 100644 --- a/eng/Subsets.props +++ b/eng/Subsets.props @@ -171,6 +171,7 @@ $(AllSubsetsExpansion)+clr.paltests+clr.paltestlist+clr.hosts+clr.jit+clr.alljits+clr.alljitscommunity+clr.spmi+clr.corelib+clr.nativecorelib+clr.tools+clr.toolstests+clr.packages $(AllSubsetsExpansion)+linuxdac+alpinedac $(AllSubsetsExpansion)+mono.runtime+provision.emsdk+mono.aotcross+mono.corelib+mono.manifests+mono.packages+mono.tools+mono.wasmruntime+mono.wasiruntime+mono.wasmworkload+mono.mscordbi + $(AllSubsetsExpansion)+apple.buildtests $(AllSubsetsExpansion)+tools.illink+tools.cdac+tools.illinktests+tools.cdactests+tools.cdacdumptests $(AllSubsetsExpansion)+host.native+host.pkg+host.tools+host.pretest+host.tests $(AllSubsetsExpansion)+libs.native+libs.sfx+libs.oob+libs.pretest+libs.tests @@ -253,6 +254,9 @@ + + + @@ -656,6 +660,12 @@ + + + + + + diff --git a/eng/pipelines/common/evaluate-default-paths.yml b/eng/pipelines/common/evaluate-default-paths.yml index 94c75642341b46..4572842233d8c3 100644 --- a/eng/pipelines/common/evaluate-default-paths.yml +++ b/eng/pipelines/common/evaluate-default-paths.yml @@ -182,6 +182,12 @@ jobs: - src/native/managed/cdac/* - src/coreclr/debug/runtimeinfo/* + # The Apple mobile MSBuild targets and the tests that cover them. The tests only drive MSBuild, so + # they run on ordinary build hosts and need none of the mobile or WebAssembly tooling. + - subset: applebuildtests + include: + - src/mono/msbuild/apple/* + - subset: installer include: exclude: diff --git a/eng/pipelines/runtime.yml b/eng/pipelines/runtime.yml index af2fc6e3392cf5..a90a3e2df02b1e 100644 --- a/eng/pipelines/runtime.yml +++ b/eng/pipelines/runtime.yml @@ -1057,6 +1057,29 @@ extends: eq(stageDependencies.EvaluatePaths.evaluate_paths.outputs['SetPathVars_installer.containsChange'], true), eq(variables['isRollingBuild'], true)) + # + # Apple mobile MSBuild targets tests + # Only when the Apple MSBuild targets change + # + # These only drive MSBuild and file orchestration in a child process, so they run on an ordinary + # macOS build host and need no Apple mobile workload or device. + - template: /eng/pipelines/common/platform-matrix.yml + parameters: + jobTemplate: /eng/pipelines/common/global-build-job.yml + buildConfig: Release + platforms: + - osx_x64 + jobParameters: + timeoutInMinutes: 30 + nameSuffix: AppleBuildTests + buildArgs: -s apple.buildtests -c $(_BuildConfig) -test + enablePublishTestResults: true + testResultsFormat: 'xunit' + condition: >- + or( + eq(stageDependencies.EvaluatePaths.evaluate_paths.outputs['SetPathVars_applebuildtests.containsChange'], true), + eq(variables['isRollingBuild'], true)) + # # WebAssembly legs # diff --git a/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj b/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj index 45fb92f779e169..878aa9dd49688c 100644 --- a/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj +++ b/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj @@ -160,6 +160,10 @@ <_R2ROutputPath>$(IntermediateOutputPath)R2R + + <_R2RAppPayloadDir>$([MSBuild]::NormalizeDirectory($(BaseIntermediateOutputPath), 'r2r-app-payload')) @@ -167,18 +171,36 @@ <_R2RDylibFiles Include="$(_R2ROutputPath)\*.r2r.dylib" /> + - + + + + + + <_R2RAppPayloadSourceFiles Include="$(OriginalPublishDir)**\*" /> + + + - + + $(_R2RAppPayloadDir) + + - + + + + + + @@ -188,6 +210,11 @@ $(TargetOS)-$(TargetArchitecture) + + + <_R2RPublishAssembly Include="$(OriginalPublishDir)*.dll" Exclude="$(OriginalPublishDir)*.resources.dll" /> diff --git a/src/mono/msbuild/apple/tests/Apple.Build.Tests/Apple.Build.Tests.csproj b/src/mono/msbuild/apple/tests/Apple.Build.Tests/Apple.Build.Tests.csproj new file mode 100644 index 00000000000000..eb2886c5aadd18 --- /dev/null +++ b/src/mono/msbuild/apple/tests/Apple.Build.Tests/Apple.Build.Tests.csproj @@ -0,0 +1,18 @@ + + + + + $(NetCoreAppToolCurrent) + enable + + + + + + + + diff --git a/src/mono/msbuild/apple/tests/Apple.Build.Tests/AppleHelixR2RTests.cs b/src/mono/msbuild/apple/tests/Apple.Build.Tests/AppleHelixR2RTests.cs new file mode 100644 index 00000000000000..63f4204a443345 --- /dev/null +++ b/src/mono/msbuild/apple/tests/Apple.Build.Tests/AppleHelixR2RTests.cs @@ -0,0 +1,475 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Apple.Build.Tests; + +// Covers the Apple Helix ReadyToRun proxy build. The test drives the real +// src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj - shipped into the test payload as +// content - with `dotnet msbuild`, and stands in for crossgen2 by planting files where it would write +// them. +// +// A Helix retry re-runs the whole work item command in the same directory, so the R2R step has to +// leave the publish directory - its own crossgen2 input - byte for byte identical, and has to rebuild +// everything it hands to the app builder from scratch on every attempt. +// +// Only MSBuild path handling is under test, so this runs on any ordinary build host: no Apple SDK, no +// device, and no workload provisioning. +public class AppleHelixR2RTests +{ + private const string ProxyProjectFileName = "ProxyProjectForAOTOnHelix.proj"; + private const string ProxyPropsFileName = "ProxyProjectForAOTOnHelix.props"; + private const string BundleStateFileName = "bundle-state.txt"; + private const string ExtraFileName = "libExtra.dylib"; + + private static readonly TimeSpan s_buildTimeout = TimeSpan.FromMinutes(5); + + private static readonly StringComparer s_pathComparer = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + // Payload the build machine ships in the work item, which crossgen2 reads and must never rewrite. + private static readonly (string RelativePath, string Content)[] s_pristinePayload = + { + ("AppleTestRunner.dll", "pristine-il:AppleTestRunner"), + ("Lib.dll", "pristine-il:Lib"), + ("Lib.resources.dll", "pristine-resources:Lib"), + ("libnative.dylib", "pristine-native"), + ("nested/data.bin", "pristine-nested-data"), + }; + + private static readonly (string RelativePath, string Content)[] s_firstAttemptR2ROutput = + { + ("AppleTestRunner.dll", "r2r-attempt1:AppleTestRunner"), + ("Lib.dll", "r2r-attempt1:Lib"), + ("FirstAttemptOnly.dll", "r2r-attempt1:FirstAttemptOnly"), + ("app.r2r.dylib", "r2r-attempt1:composite"), + }; + + private static readonly (string RelativePath, string Content)[] s_secondAttemptR2ROutput = + { + ("AppleTestRunner.dll", "r2r-attempt2:AppleTestRunner"), + ("Lib.dll", "r2r-attempt2:Lib"), + ("app.r2r.dylib", "r2r-attempt2:composite"), + }; + + private readonly ITestOutputHelper _testOutput; + + public AppleHelixR2RTests(ITestOutputHelper testOutput) => _testOutput = testOutput; + + [Fact] + public void R2RAppPayloadLeavesPublishPristineAndIsRebuiltOnRetry() + { + DirectoryInfo workItemRoot = Directory.CreateTempSubdirectory("apple-helix-r2r-"); + try + { + string publishDir = Path.Combine(workItemRoot.FullName, "publish"); + string extraFilesDir = Path.Combine(workItemRoot.FullName, "extraFiles"); + string crossgenOutputDir = Path.Combine(workItemRoot.FullName, "crossgen-output"); + string r2rIntermediateDir = Path.Combine(workItemRoot.FullName, "obj", "R2R"); + string appPayloadDir = Path.Combine(workItemRoot.FullName, "obj", "r2r-app-payload"); + + CreateWorkItemLayout(workItemRoot.FullName, publishDir, extraFilesDir); + + Dictionary publishManifest = Manifest(publishDir); + + RunAttempt(workItemRoot.FullName, publishDir, crossgenOutputDir, r2rIntermediateDir, appPayloadDir, s_firstAttemptR2ROutput); + AssertAttempt(workItemRoot.FullName, publishDir, publishManifest, extraFilesDir, appPayloadDir, s_firstAttemptR2ROutput); + + RunAttempt(workItemRoot.FullName, publishDir, crossgenOutputDir, r2rIntermediateDir, appPayloadDir, s_secondAttemptR2ROutput); + AssertAttempt(workItemRoot.FullName, publishDir, publishManifest, extraFilesDir, appPayloadDir, s_secondAttemptR2ROutput); + } + finally + { + TryDeleteDirectory(workItemRoot.FullName); + } + } + + private static void CreateWorkItemLayout(string workItemRoot, string publishDir, string extraFilesDir) + { + foreach ((string relativePath, string content) in s_pristinePayload) + WriteFile(Path.Combine(publishDir, ToNativePath(relativePath)), content); + + WriteFile(Path.Combine(extraFilesDir, ExtraFileName), "extra-native"); + + string proxyProject = Path.Combine(AppContext.BaseDirectory, "data", ProxyProjectFileName); + Assert.True(File.Exists(proxyProject), $"Expected the proxy project to be copied into the test payload at '{proxyProject}'."); + File.Copy(proxyProject, Path.Combine(publishDir, ProxyProjectFileName)); + + WriteFile(Path.Combine(publishDir, ProxyPropsFileName), BuildProxyPropsStub()); + + // The work item layout is created under the system temp directory, so shield the proxy + // project from any Directory.Build.props/targets that might sit above it. + WriteFile(Path.Combine(workItemRoot, "Directory.Build.props"), ""); + WriteFile(Path.Combine(workItemRoot, "Directory.Build.targets"), ""); + + // The SDK resolver walks up from the working directory looking for a global.json, which is a + // separate lookup from the Directory.Build one above and is not covered by it. A stray + // global.json anywhere above the temp directory pins the child to an SDK version the resolved + // host does not have, and the child then fails before MSBuild even starts. Wasm.Build.Tests + // shields its own child builds the same way. + WriteFile(Path.Combine(workItemRoot, "global.json"), "{}"); + } + + // The props file the proxy project imports. On Helix it carries the settings of the test project + // being proxied; here it stands in for crossgen2 and records what the R2R target handed to the + // Apple app builder. + private static string BuildProxyPropsStub() + { + var targetFramework = new FrameworkName(AppContext.TargetFrameworkName!); + return $""" + + + net{targetFramework.Version.Major}.{targetFramework.Version.Minor} + <_TestCrossgenOutputDir>$([MSBuild]::NormalizeDirectory($(TestRootDir), '..', 'crossgen-output')) + <_TestBundleStateFile>$([MSBuild]::NormalizePath($(TestRootDir), '..', '{BundleStateFileName}')) + + + + + <_TestCrossgenOutput Include="$(_TestCrossgenOutputDir)*" /> + + + + + + + + <_TestBundleState Include="AppleBuildDir=$(AppleBuildDir)" /> + <_TestBundleState Include="@(AppleAssembliesToBundle->'Assembly=%(FullPath)')" /> + <_TestBundleState Include="@(AppleNativeFilesToBundle->'Native=%(FullPath)')" /> + + + + + + """; + } + + private void RunAttempt( + string workItemRoot, + string publishDir, + string crossgenOutputDir, + string r2rIntermediateDir, + string appPayloadDir, + (string RelativePath, string Content)[] r2rOutput) + { + ReplaceDirectoryContent(crossgenOutputDir, r2rOutput); + + // A previous attempt that was killed mid write leaves output behind in both directories. + // Neither leftover may reach the app: the R2R intermediate dir is wiped before compiling, + // the app payload dir is recreated before the payload is staged into it. + WriteFile(Path.Combine(r2rIntermediateDir, "KilledAttempt.dll"), "leftover-r2r-output"); + WriteFile(Path.Combine(appPayloadDir, "stale-marker.txt"), "leftover-payload-file"); + WriteFile(Path.Combine(appPayloadDir, "bin-previous", "leftover.txt"), "leftover-payload-tree"); + WriteFile(Path.Combine(appPayloadDir, "app.r2r.dylib"), "leftover-composite"); + + string bundleStateFile = Path.Combine(workItemRoot, BundleStateFileName); + File.Delete(bundleStateFile); + + RunMSBuild(workItemRoot, publishDir); + + Assert.True(File.Exists(bundleStateFile), $"The proxy build did not record the bundle state, so '{ProxyPropsFileName}' was not imported."); + } + + private void RunMSBuild(string workItemRoot, string publishDir) + { + string host = ResolveDotNetHost(); + var startInfo = new ProcessStartInfo(host) + { + WorkingDirectory = publishDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + startInfo.ArgumentList.Add("msbuild"); + startInfo.ArgumentList.Add(ProxyProjectFileName); + startInfo.ArgumentList.Add("-t:_PrepareForAppleBuildAppOnHelix;_PrepareR2RItemsOnHelix;_AddR2RFilesToAppleBundle"); + startInfo.ArgumentList.Add("-p:PublishReadyToRunContainerFormat=macho"); + startInfo.ArgumentList.Add("-nodeReuse:false"); + startInfo.ArgumentList.Add("-nologo"); + startInfo.ArgumentList.Add("-v:minimal"); + startInfo.Environment["HELIX_WORKITEM_ROOT"] = workItemRoot; + startInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + // The repo build sets this, and it makes the child pick up the wrong SDK. + startInfo.Environment.Remove("MSBuildSDKsPath"); + + // The repo build environment points these at its own dotnet, and the child has to use the one + // that was just resolved. + string hostDir = Path.GetDirectoryName(host)!; + startInfo.Environment["DOTNET_ROOT"] = hostDir; + startInfo.Environment["DOTNET_INSTALL_DIR"] = hostDir; + // An empty PATH entry means the working directory on unix, so do not leave a trailing + // separator behind when the parent has no PATH. + string? path = Environment.GetEnvironmentVariable("PATH"); + startInfo.Environment["PATH"] = string.IsNullOrEmpty(path) + ? hostDir + : $"{hostDir}{Path.PathSeparator}{path}"; + + var output = new StringBuilder(); + using var process = new Process { StartInfo = startInfo }; + process.OutputDataReceived += (_, e) => AppendLine(output, e.Data); + process.ErrorDataReceived += (_, e) => AppendLine(output, e.Data); + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit((int)s_buildTimeout.TotalMilliseconds)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + throw new XunitException($"The proxy project build did not finish within {s_buildTimeout}.{Environment.NewLine}{output}"); + } + + // Flushes the redirected streams. + process.WaitForExit(); + + if (process.ExitCode != 0) + throw new XunitException($"The proxy project build failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); + + _testOutput.WriteLine(output.ToString()); + } + + private void AssertAttempt( + string workItemRoot, + string publishDir, + Dictionary expectedPublishManifest, + string extraFilesDir, + string appPayloadDir, + (string RelativePath, string Content)[] r2rOutput) + { + AssertManifest("publish directory", expectedPublishManifest, Manifest(publishDir)); + + Dictionary expectedPayload = new(expectedPublishManifest, s_pathComparer); + foreach ((string relativePath, string content) in r2rOutput) + expectedPayload[relativePath] = Hash(Encoding.UTF8.GetBytes(content)); + + AssertManifest("app payload directory", expectedPayload, Manifest(appPayloadDir)); + + (string appleBuildDir, List assemblies, List nativeFiles) = ReadBundleState(workItemRoot); + + Assert.Equal(NormalizeDirectory(appPayloadDir), NormalizeDirectory(appleBuildDir), s_pathComparer); + + IEnumerable topLevelAssemblies = expectedPayload.Keys + .Where(relativePath => IsTopLevel(relativePath) + && relativePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + && !relativePath.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)); + AssertPathSet("AppleAssembliesToBundle", topLevelAssemblies.Select(relativePath => Path.Combine(appPayloadDir, ToNativePath(relativePath))), assemblies); + + IEnumerable payloadNativeFiles = expectedPayload.Keys + .Where(relativePath => !IsTopLevel(relativePath) || !relativePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(relativePath => Path.Combine(appPayloadDir, ToNativePath(relativePath))); + AssertPathSet("AppleNativeFilesToBundle", payloadNativeFiles.Append(Path.Combine(extraFilesDir, ExtraFileName)), nativeFiles); + } + + private static (string AppleBuildDir, List Assemblies, List NativeFiles) ReadBundleState(string workItemRoot) + { + string? appleBuildDir = null; + List assemblies = new(); + List nativeFiles = new(); + + foreach (string line in File.ReadAllLines(Path.Combine(workItemRoot, BundleStateFileName))) + { + string[] parts = line.Split('=', 2); + switch (parts) + { + case ["AppleBuildDir", string value]: + appleBuildDir = value; + break; + case ["Assembly", string value]: + assemblies.Add(value); + break; + case ["Native", string value]: + nativeFiles.Add(value); + break; + default: + throw new XunitException($"Unexpected line in {BundleStateFileName}: '{line}'."); + } + } + + if (appleBuildDir is null) + throw new XunitException($"{BundleStateFileName} did not record AppleBuildDir."); + + return (appleBuildDir, assemblies, nativeFiles); + } + + private static void AssertManifest(string what, Dictionary expected, Dictionary actual) + { + List differences = new(); + + foreach ((string relativePath, string hash) in expected.OrderBy(entry => entry.Key, s_pathComparer)) + { + if (!actual.TryGetValue(relativePath, out string? actualHash)) + differences.Add($" missing: {relativePath}"); + else if (actualHash != hash) + differences.Add($" content changed: {relativePath}"); + } + + foreach (string relativePath in actual.Keys.Where(key => !expected.ContainsKey(key)).OrderBy(key => key, s_pathComparer)) + differences.Add($" unexpected: {relativePath}"); + + if (differences.Count > 0) + throw new XunitException($"Unexpected content in the {what}:{Environment.NewLine}{string.Join(Environment.NewLine, differences)}"); + } + + private static void AssertPathSet(string what, IEnumerable expected, IEnumerable actual) + { + List expectedPaths = expected.Select(Path.GetFullPath).Order(s_pathComparer).ToList(); + List actualPaths = actual.Select(Path.GetFullPath).Order(s_pathComparer).ToList(); + + if (!expectedPaths.SequenceEqual(actualPaths, s_pathComparer)) + { + throw new XunitException( + $"Unexpected {what}:{Environment.NewLine}expected:{Environment.NewLine} {string.Join($"{Environment.NewLine} ", expectedPaths)}" + + $"{Environment.NewLine}actual:{Environment.NewLine} {string.Join($"{Environment.NewLine} ", actualPaths)}"); + } + } + + private static Dictionary Manifest(string root) + { + Dictionary manifest = new(s_pathComparer); + foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + manifest[ToRelativePath(root, file)] = Hash(File.ReadAllBytes(file)); + + return manifest; + } + + // The proxy project is an SDK project, so the child needs a full SDK and not just a runtime host. + // Everything is resolved to an absolute path here because the child runs with a different working + // directory, and nothing is looked up relative to the current one for the same reason. + private static string ResolveDotNetHost() + { + string fileName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + List candidates = new(); + + // The SDK sets this for everything it launches, so under `dotnet build`/`dotnet test` it is + // already the muxer that is driving this build. + AddHost(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH")); + + // The xunit console runner is started as `dotnet exec ...`, so the current process is a muxer. + AddHost(Environment.ProcessPath); + + // Set by the repo build scripts. + AddHostIn(Environment.GetEnvironmentVariable("DOTNET_ROOT")); + + // The dotnet the repo provisions, found by walking up from the test assembly rather than from + // the working directory, which the test host owns. + AddHostIn(FindRepoDotNetDirectory(fileName)); + + foreach (string candidate in candidates) + { + if (HasSdk(candidate)) + return candidate; + } + + throw new XunitException( + $"Could not find a dotnet host with an SDK. Tried:{Environment.NewLine} {string.Join($"{Environment.NewLine} ", candidates)}"); + + void AddHost(string? host) + { + if (!string.IsNullOrEmpty(host) && Path.GetFileName(host).Equals(fileName, StringComparison.OrdinalIgnoreCase)) + candidates.Add(Path.GetFullPath(host)); + } + + void AddHostIn(string? directory) + { + if (!string.IsNullOrEmpty(directory)) + candidates.Add(Path.GetFullPath(Path.Combine(directory, fileName))); + } + } + + private static string? FindRepoDotNetDirectory(string fileName) + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + string candidate = Path.Combine(directory.FullName, ".dotnet"); + if (File.Exists(Path.Combine(candidate, fileName))) + return candidate; + } + + return null; + } + + // A host that only ships a runtime cannot restore or build the proxy project, so keep looking. + private static bool HasSdk(string host) + { + if (!File.Exists(host)) + return false; + + string sdkDir = Path.Combine(Path.GetDirectoryName(host)!, "sdk"); + return Directory.Exists(sdkDir) && Directory.EnumerateDirectories(sdkDir).Any(); + } + + private static void ReplaceDirectoryContent(string directory, (string RelativePath, string Content)[] files) + { + DeleteDirectory(directory); + foreach ((string relativePath, string content) in files) + WriteFile(Path.Combine(directory, ToNativePath(relativePath)), content); + } + + private static void WriteFile(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + // Used to set the test up, so a directory that cannot be emptied has to fail the test instead of + // leaving content from the previous attempt in place and asserting against the wrong inputs. + private static void DeleteDirectory(string path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + } + + // Cleanup only: the work item root lives under the system temp directory, and failing to remove it + // must not turn a passing test into a failing one. + private static void TryDeleteDirectory(string path) + { + try + { + DeleteDirectory(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static void AppendLine(StringBuilder output, string? line) + { + if (line is not null) + { + lock (output) + output.AppendLine(line); + } + } + + private static bool IsTopLevel(string relativePath) => !relativePath.Contains('/'); + + private static string ToNativePath(string relativePath) => relativePath.Replace('/', Path.DirectorySeparatorChar); + + private static string ToRelativePath(string root, string path) => Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/'); + + private static string NormalizeDirectory(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + + private static string Hash(byte[] content) => Convert.ToHexString(SHA256.HashData(content)); +} diff --git a/src/mono/msbuild/apple/tests/Directory.Build.props b/src/mono/msbuild/apple/tests/Directory.Build.props new file mode 100644 index 00000000000000..0d9929a957ea51 --- /dev/null +++ b/src/mono/msbuild/apple/tests/Directory.Build.props @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/mono/msbuild/apple/tests/Directory.Build.targets b/src/mono/msbuild/apple/tests/Directory.Build.targets new file mode 100644 index 00000000000000..b3e6fe3fa3a6b3 --- /dev/null +++ b/src/mono/msbuild/apple/tests/Directory.Build.targets @@ -0,0 +1,5 @@ + + + +