diff --git a/README.ja.md b/README.ja.md index 92a3054..4a97764 100644 --- a/README.ja.md +++ b/README.ja.md @@ -314,10 +314,10 @@ RelaxVersionerは、ビルド後に、以下の位置にファイルを保存し 正確には: -* ビルド時は`$(IntermediateOutputPath)`です。 -* NuGetパッケージ生成時は`$(NuspecOutputPath)`です。 +* 通常ビルド時は`$(IntermediateOutputPath)`です。 +* NuGetパッケージ生成時は、pack requestごとの内部ワークスペースが中間出力配下に作成されます。正確なパスは内部実装であり、固定前提で参照しないでください。 -例えば、`FooBarProject/obj/Debug/net6.0/` のようなディレクトリ階層です。以下に保存するファイルを示します: +通常ビルドでは、例えば `FooBarProject/obj/Debug/net6.0/` のようなディレクトリ階層になります。以下に保存するファイルを示します: * `RelaxVersioner_Metadata.cs` : バージョン属性や`ThisAssembly`クラスの定義を含む、ソースコードです。RelaxVersionerの中心的な役割を果たします。 * `RelaxVersioner_Properties.xml` : RelaxVersionerがバージョン計算を行う直前の、MSBuildの全てのプロパティを、XML形式でダンプしたものです。 @@ -334,7 +334,8 @@ RelaxVersionerは、ビルド後に、以下の位置にファイルを保存し 例えば、`RelaxVersioner_ShortVersion.txt`には、`2.5.4`のような文字列が格納されているので、 ビルド成果物をサーバーにアップロードする際に、バージョン番号をファイル名に追加して保存する事が出来るかもしれません。 -これらの情報をMSBuildターゲット内から参照する場合は、テキストファイルにアクセスすることなく、以下のようにプロパティを使用できます: +これらの情報をMSBuildターゲット内から参照する場合は、テキストファイルにアクセスすることなく、以下のようにプロパティを使用できます。 +特にpack時は、ファイルパスではなくこれらのプロパティを使用することを推奨します: ```xml diff --git a/README.md b/README.md index 0f68cdd..b06300e 100644 --- a/README.md +++ b/README.md @@ -312,10 +312,10 @@ RelaxVersioner saves the files in the following location after build: To be precise: -* `$(IntermediateOutputPath)` at build time. -* `$(NuspecOutputPath)` at NuGet package generation. +* `$(IntermediateOutputPath)` for normal builds. +* A request-scoped internal workspace under the intermediate output root during NuGet pack. The exact path is internal and should not be treated as stable. -For example, `FooBarProject/obj/Debug/net6.0/` directory hierarchy. Here are the files to save: +For normal builds, this is typically a directory hierarchy such as `FooBarProject/obj/Debug/net6.0/`. Here are the files to save: * `RelaxVersioner_Metadata.cs` : Source code including version attributes and `ThisAssembly` class definition, which is the core feature of RelaxVersioner. * `RelaxVersioner_Properties.xml` : A dump of all MSBuild properties in XML format, just before RelaxVersioner calculates the version. @@ -328,7 +328,8 @@ For example, `FooBarProject/obj/Debug/net6.0/` directory hierarchy. Here are the If you want to reference this information from your program, you can get it from the version attributes or `ThisAssembly`. Other, XML or text files can be referenced by CI/CD (Continuous Integration and Continuous Deployment) to apply version information to the build process. For example, `RelaxVersioner_ShortVersion.txt` contains a string like `2.5.4`, so you may be able to save your build artifacts with a version number when you upload them to the server. -If you want to reference this information from within the MSBuild target, you can use the properties as follows without having to access the text file: +If you want to reference this information from within the MSBuild target, you can use the properties as follows without having to access the text file. +Especially during pack, prefer these properties over hard-coding file paths: ```xml diff --git a/RelaxVersioner.Core.Tests/PackTargetsTests.cs b/RelaxVersioner.Core.Tests/PackTargetsTests.cs new file mode 100644 index 0000000..00d904d --- /dev/null +++ b/RelaxVersioner.Core.Tests/PackTargetsTests.cs @@ -0,0 +1,173 @@ +//////////////////////////////////////////////////////////////////////////////////////// +// +// RelaxVersioner - Git tag/branch based, full-automatic version generator. +// Copyright (c) Kouji Matsui (@kozy_kekyo, @kekyo@mi.kekyo.net) +// +// Licensed under Apache-v2: https://opensource.org/licenses/Apache-2.0 +// +//////////////////////////////////////////////////////////////////////////////////////// + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +namespace RelaxVersioner; + +[NonParallelizable] +public sealed class PackTargetsTests +{ + private static readonly SemaphoreSlim tasksAssemblyLock = new(1, 1); + private static string? tasksAssemblyPath; + + [Test] + public async Task RelaxVersionerPackPrepare_UsesUniqueWorkspacePerBuildRequest() + { +#if !NET8_0 + Assert.Ignore("This integration test runs once on net8.0."); +#else + + var repositoryRoot = GetRepositoryRoot(); + var builtTasksAssemblyPath = await EnsureTasksAssemblyPathAsync(repositoryRoot); + var tempPath = Path.Combine( + Path.GetTempPath(), + $"RelaxVersionerPackTargetsTest_{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(tempPath); + + var projectPath = Path.Combine(tempPath, "PackWorkspaceProbe.csproj"); + await File.WriteAllTextAsync( + projectPath, + $$""" + + + + + net8.0 + true + <_RVB_MSBuildTaskPath>{{EscapePath(builtTasksAssemblyPath)}} + + + + + + + + + + + <_PackRequests Include="$(MSBuildProjectFullPath)" AdditionalProperties="RequestMarker=first" /> + <_PackRequests Include="$(MSBuildProjectFullPath)" AdditionalProperties="RequestMarker=second" /> + + + + + + """); + + await TestUtilities.RunCommandAsync( + "dotnet", + tempPath, + $"msbuild \"{projectPath}\" -t:RunParallelPackLikeRequests -m:2 -nr:false"); + + var firstPath = (await File.ReadAllTextAsync(Path.Combine(tempPath, "obj", "first.log"))).Trim(); + var secondPath = (await File.ReadAllTextAsync(Path.Combine(tempPath, "obj", "second.log"))).Trim(); + var legacyPackPath = Path.GetFullPath( + Path.Combine(tempPath, "obj", "Debug", "RelaxVersioner_Properties.xml")); + + Assert.Multiple(() => + { + Assert.That(firstPath, Is.Not.Empty); + Assert.That(secondPath, Is.Not.Empty); + Assert.That(firstPath, Is.Not.EqualTo(secondPath), + "Pack-like requests should not share the same properties path."); + Assert.That(firstPath, Does.Contain("RelaxVersioner_Pack")); + Assert.That(secondPath, Does.Contain("RelaxVersioner_Pack")); + Assert.That(firstPath, Does.EndWith("RelaxVersioner_Properties.xml")); + Assert.That(secondPath, Does.EndWith("RelaxVersioner_Properties.xml")); + Assert.That(Path.GetFullPath(firstPath), Is.Not.EqualTo(legacyPackPath)); + Assert.That(Path.GetFullPath(secondPath), Is.Not.EqualTo(legacyPackPath)); + Assert.That(Path.GetDirectoryName(firstPath), Is.Not.EqualTo(Path.GetDirectoryName(secondPath)), + "Each request should resolve a different workspace directory."); + }); + } + finally + { + if (Directory.Exists(tempPath)) + { + try { Directory.Delete(tempPath, true); } catch { } + } + } +#endif + } + + private static async Task EnsureTasksAssemblyPathAsync(string repositoryRoot) + { + await tasksAssemblyLock.WaitAsync(); + try + { + if (!string.IsNullOrWhiteSpace(tasksAssemblyPath) && + File.Exists(tasksAssemblyPath)) + { + return tasksAssemblyPath; + } + + var projectPath = Path.Combine( + repositoryRoot, + "RelaxVersioner.Tasks", + "RelaxVersioner.Tasks.csproj"); + + await TestUtilities.RunCommandAsync( + "dotnet", + repositoryRoot, + $"build \"{projectPath}\" -c Debug -f netstandard2.0 -p:BOOTSTRAP=True -nr:false"); + + tasksAssemblyPath = Path.Combine( + repositoryRoot, + "RelaxVersioner.Tasks", + "bin", + "Debug", + "netstandard2.0", + "RelaxVersioner.Tasks.dll"); + + Assert.That(File.Exists(tasksAssemblyPath), Is.True, + $"Task assembly should exist: {tasksAssemblyPath}"); + return tasksAssemblyPath; + } + finally + { + tasksAssemblyLock.Release(); + } + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + + while (directory != null) + { + if (directory.EnumerateFiles("RelaxVersioner.sln", SearchOption.TopDirectoryOnly).Any()) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Repository root could not be located."); + } + + private static string EscapePath(string path) => + path.Replace("&", "&").Replace("'", "'").Replace("\"", """); +} diff --git a/RelaxVersioner.Core.Tests/RelaxVersioner.Core.Tests.csproj b/RelaxVersioner.Core.Tests/RelaxVersioner.Core.Tests.csproj index b583714..ad8fc3c 100644 --- a/RelaxVersioner.Core.Tests/RelaxVersioner.Core.Tests.csproj +++ b/RelaxVersioner.Core.Tests/RelaxVersioner.Core.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/RelaxVersioner.Core.Tests/TestUtilities.cs b/RelaxVersioner.Core.Tests/TestUtilities.cs index af09014..2cd39bd 100644 --- a/RelaxVersioner.Core.Tests/TestUtilities.cs +++ b/RelaxVersioner.Core.Tests/TestUtilities.cs @@ -8,6 +8,7 @@ //////////////////////////////////////////////////////////////////////////////////////// using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading.Tasks; @@ -27,60 +28,48 @@ public static async Task ReadAllTextAsync(string path) public static async Task RunGitCommandAsync(string workingDirectory, string arguments) { - using var process = new Process + try { - StartInfo = new ProcessStartInfo - { - FileName = "git", - Arguments = arguments, - WorkingDirectory = workingDirectory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - } - }; - - process.Start(); - - // Use Task.Run to wrap the synchronous WaitForExit for compatibility - await Task.Run(() => process.WaitForExit()); - - if (process.ExitCode != 0) + await RunProcessCoreAsync("git", workingDirectory, arguments, null); + } + catch (InvalidOperationException ex) { - var error = process.StandardError.ReadToEnd(); - throw new InvalidOperationException($"Git command failed: git {arguments}\nError: {error}"); + throw new InvalidOperationException($"Git command failed: git {arguments}\nError: {ex.Message}", ex); } } public static async Task RunGitCommandWithOutputAsync(string workingDirectory, string arguments) { - using var process = new Process + try { - StartInfo = new ProcessStartInfo - { - FileName = "git", - Arguments = arguments, - WorkingDirectory = workingDirectory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - } - }; - - process.Start(); - - // Use Task.Run to wrap the synchronous WaitForExit for compatibility - await Task.Run(() => process.WaitForExit()); - - if (process.ExitCode != 0) + var (_, output, _) = await RunProcessCoreAsync("git", workingDirectory, arguments, null); + return output; + } + catch (InvalidOperationException ex) { - var error = process.StandardError.ReadToEnd(); - throw new InvalidOperationException($"Git command failed: git {arguments}\nError: {error}"); + throw new InvalidOperationException($"Git command failed: git {arguments}\nError: {ex.Message}", ex); } - - return process.StandardOutput.ReadToEnd(); + } + + public static async Task RunCommandAsync( + string fileName, + string workingDirectory, + string arguments, + IReadOnlyDictionary? environmentVariables = null) => + await RunProcessCoreAsync(fileName, workingDirectory, arguments, environmentVariables); + + public static async Task RunCommandWithOutputAsync( + string fileName, + string workingDirectory, + string arguments, + IReadOnlyDictionary? environmentVariables = null) + { + var (_, output, _) = await RunProcessCoreAsync( + fileName, + workingDirectory, + arguments, + environmentVariables); + return output; } public static async Task InitializeGitRepositoryWithMainBranch(string workingDirectory) @@ -110,4 +99,51 @@ public static async Task InitializeGitRepositoryWithMainBranch(string workingDir } } } -} \ No newline at end of file + + private static async Task<(int ExitCode, string StandardOutput, string StandardError)> RunProcessCoreAsync( + string fileName, + string workingDirectory, + string arguments, + IReadOnlyDictionary? environmentVariables) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + if (environmentVariables != null) + { + foreach (var (key, value) in environmentVariables) + { + process.StartInfo.Environment[key] = value ?? string.Empty; + } + } + + process.Start(); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + + await Task.Run(() => process.WaitForExit()); + + var standardOutput = await standardOutputTask; + var standardError = await standardErrorTask; + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Command failed: {fileName} {arguments}\nError: {standardError}"); + } + + return (process.ExitCode, standardOutput, standardError); + } +} diff --git a/RelaxVersioner.Core/RelaxVersioner.Core.csproj b/RelaxVersioner.Core/RelaxVersioner.Core.csproj index bbd4318..ed19efd 100644 --- a/RelaxVersioner.Core/RelaxVersioner.Core.csproj +++ b/RelaxVersioner.Core/RelaxVersioner.Core.csproj @@ -17,7 +17,7 @@ + Include="RelaxVersioner" Version="3.21.0" PrivateAssets="all" /> diff --git a/RelaxVersioner.Tasks/RelaxVersioner.Tasks.csproj b/RelaxVersioner.Tasks/RelaxVersioner.Tasks.csproj index 860e247..f092a33 100644 --- a/RelaxVersioner.Tasks/RelaxVersioner.Tasks.csproj +++ b/RelaxVersioner.Tasks/RelaxVersioner.Tasks.csproj @@ -28,7 +28,7 @@ + Include="RelaxVersioner" Version="3.21.0" PrivateAssets="all" /> diff --git a/RelaxVersioner.Tasks/ResolvePackWorkspaceTask.cs b/RelaxVersioner.Tasks/ResolvePackWorkspaceTask.cs new file mode 100644 index 0000000..79f5ca0 --- /dev/null +++ b/RelaxVersioner.Tasks/ResolvePackWorkspaceTask.cs @@ -0,0 +1,130 @@ +//////////////////////////////////////////////////////////////////////////////////////// +// +// RelaxVersioner - Git tag/branch based, full-automatic version generator. +// Copyright (c) Kouji Matsui (@kozy_kekyo, @kekyo@mi.kekyo.net) +// +// Licensed under Apache-v2: https://opensource.org/licenses/Apache-2.0 +// +//////////////////////////////////////////////////////////////////////////////////////// + +using System; +using System.IO; + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace RelaxVersioner; + +public sealed class ResolvePackWorkspaceTask : Task +{ + [Required] + public string? RootPath + { + get; + set; + } + + public string? TargetFramework + { + get; + set; + } + + [Output] + public string? RequestKey + { + get; + private set; + } + + [Output] + public string? WorkspacePath + { + get; + private set; + } + + public override bool Execute() + { + if (string.IsNullOrWhiteSpace(this.RootPath)) + { + this.Log.LogError("RelaxVersioner.ResolvePackWorkspaceTask: Required root path."); + return false; + } + + var requestKey = + this.GetBuildRequestKey() ?? + $"req_{Guid.NewGuid():N}"; + var targetFramework = + SanitizePathSegment(this.TargetFramework) switch + { + "" => "_outer", + var value => value, + }; + + this.RequestKey = requestKey; + this.WorkspacePath = Path.Combine( + Path.GetFullPath(this.RootPath), + "RelaxVersioner_Pack", + targetFramework, + requestKey); + + this.Log.LogMessage( + $"RelaxVersioner.ResolvePackWorkspaceTask: Resolved pack workspace, RequestKey={this.RequestKey}, Path={this.WorkspacePath}"); + return true; + } + + private string? GetBuildRequestKey() + { + var buildRequestEntry = this.BuildEngine?.GetField("_requestEntry"); + var buildRequest = + buildRequestEntry?.GetProperty("BuildRequest") ?? + buildRequestEntry?.GetProperty("Request"); + + var requestKey = + GetPropertyString(buildRequest, "GlobalRequestId") ?? + GetPropertyString(buildRequest, "RequestId") ?? + GetPropertyString(buildRequest, "NodeRequestId"); + + if (!string.IsNullOrWhiteSpace(requestKey)) + { + return $"req_{SanitizePathSegment(requestKey)}"; + } + + var requestConfiguration = buildRequestEntry?.GetProperty("RequestConfiguration"); + requestKey = + GetPropertyString(requestConfiguration, "ConfigurationId") ?? + GetPropertyString(requestConfiguration, "GlobalConfigurationId"); + + return string.IsNullOrWhiteSpace(requestKey) ? + null : + $"cfg_{SanitizePathSegment(requestKey)}"; + } + + private static string? GetPropertyString(object? instance, string name) => + instance?.GetProperty(name)?.ToString(); + + private static string SanitizePathSegment(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var buffer = value!.ToCharArray(); + + for (var index = 0; index < buffer.Length; index++) + { + var ch = buffer[index]; + if ((ch == Path.DirectorySeparatorChar) || + (ch == Path.AltDirectorySeparatorChar) || + (Array.IndexOf(invalidChars, ch) >= 0)) + { + buffer[index] = '_'; + } + } + + return new string(buffer); + } +} diff --git a/RelaxVersioner/RelaxVersioner.csproj b/RelaxVersioner/RelaxVersioner.csproj index bcc896a..dc9aa82 100644 --- a/RelaxVersioner/RelaxVersioner.csproj +++ b/RelaxVersioner/RelaxVersioner.csproj @@ -36,7 +36,7 @@ + Include="RelaxVersioner" Version="3.21.0" PrivateAssets="all" /> diff --git a/RelaxVersioner/build/RelaxVersioner.targets b/RelaxVersioner/build/RelaxVersioner.targets index 96f0d04..03b865b 100644 --- a/RelaxVersioner/build/RelaxVersioner.targets +++ b/RelaxVersioner/build/RelaxVersioner.targets @@ -45,6 +45,9 @@ + @@ -299,11 +302,22 @@ $([System.IO.Path]::Combine('$(RelaxVersionerToolingDir)','$(_RVB_ExecutableName)')) $([System.IO.Path]::Combine('$(MSBuildProjectDirectory)','$(NuspecOutputPath)')) + <_RVB_PackWorkspaceRoot Condition="'$(RelaxVersionerDumpBasePath)' == ''">$(RelaxVersionerOutputDir) + <_RVB_PackWorkspaceRoot Condition="'$(RelaxVersionerDumpBasePath)' != ''">$([System.IO.Path]::GetFullPath('$(RelaxVersionerDumpBasePath)')) + + + + + + + - <_RVB_DumpBasePath Condition="'$(RelaxVersionerDumpBasePath)' == ''">$(RelaxVersionerOutputDir) - <_RVB_DumpBasePath Condition="'$(RelaxVersionerDumpBasePath)' != ''">$([System.IO.Path]::GetFullPath('$(RelaxVersionerDumpBasePath)')) + <_RVB_DumpBasePath>$(_RVB_PackWorkspacePath) $([System.IO.Path]::Combine('$(_RVB_DumpBasePath)','RelaxVersioner_Properties.xml')) $([System.IO.Path]::Combine('$(_RVB_DumpBasePath)','RelaxVersioner_Result.xml'))