From fddfa8fc2e7549c702572036764bb5be04b8ac08 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 10 Aug 2026 13:59:15 +0200 Subject: [PATCH 1/4] Place file-based app artifacts into repo's artifacts dir if used --- documentation/general/dotnet-run-file.md | 7 ++- .../VirtualProjectBuilder.cs | 5 +- .../sdk/UseArtifactsOutputPath.props | 2 +- .../Microsoft.NET.DefaultArtifactsPath.props | 8 +++ .../Run/RunFileTests_BuildCommands.cs | 61 +++++++++++++++++++ .../Run/RunFileTests_CscOnlyAndApi.cs | 12 ++-- 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 3e0db6afaadf..a56ce6273374 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -40,7 +40,8 @@ Additionally, the implicit project file has the following customizations: - The following are virtual only, i.e., not preserved after [converting to a project](#grow-up): - - `ArtifactsPath` is set to a [temp directory](#build-outputs). + - `ArtifactsPath` is set to a [temp directory](#build-outputs), + unless [artifacts output layout][artifacts-output] is enabled. - `PublishDir` and `PackageOutputPath` are set to `./artifacts/` so the outputs of `dotnet publish` and `dotnet pack` are next to the file-based app. @@ -156,7 +157,9 @@ and the conversion process only copying the items that were included in the orig ## Build outputs -Build outputs are placed under a subdirectory whose name is hashed file path of the entry point +If [artifacts output layout][artifacts-output] is enabled, build outputs of the file-based app are placed there +(except caching markers which are placed in the global temp directory described next). +Otherwise, build outputs are placed under a subdirectory whose name is hashed file path of the entry point inside a temp or app data directory which should be owned by and unique to the current user per [runtime guidelines][temp-guidelines]. The subdirectory is created by the SDK CLI with permissions restricting access to it to the current user (`0700`) and the run fails if that is not possible. Note that it is possible for multiple users to run the same file-based program, however each user's run uses different build artifacts since the base directory is unique per user. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs index fc9548c650e1..7f70e4326340 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs @@ -628,15 +628,14 @@ internal static void WriteProjectFile( Debug.Assert(!string.IsNullOrWhiteSpace(artifactsPath)); Debug.Assert(entryPointFilePath is not null); - // Note that ArtifactsPath needs to be specified before Sdk.props + // Note that FileBasedAppArtifactsPath needs to be specified before Sdk.props // (usually it's recommended to specify it in Directory.Build.props // but importing Sdk.props manually afterwards also works). writer.WriteLine($""" - false - {EscapeValue(artifactsPath)} + {EscapeValue(artifactsPath)} {EscapeValue(Path.GetFileNameWithoutExtension(entryPointFilePath))} $(AssemblyName) artifacts/$(AssemblyName) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/sdk/UseArtifactsOutputPath.props b/src/Tasks/Microsoft.NET.Build.Tasks/sdk/UseArtifactsOutputPath.props index 1dd11c8d49ad..88425f9e864e 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/sdk/UseArtifactsOutputPath.props +++ b/src/Tasks/Microsoft.NET.Build.Tasks/sdk/UseArtifactsOutputPath.props @@ -15,7 +15,7 @@ Copyright (c) .NET Foundation. All rights reserved. If the .props file is not imported here, it will be imported from Microsoft.NET.DefaultOutputPaths.targets, so that artifacts output properties can be set directly in the project file too (only in that case they won't affect the intermediate output). --> + Condition="'$(UseArtifactsOutput)' == 'true' Or '$(ArtifactsPath)' != '' Or '$(FileBasedAppArtifactsPath)' != ''"/> true diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props index d06c6aab0d1c..8deceb4224e3 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props @@ -49,4 +49,12 @@ Copyright (c) .NET Foundation. All rights reserved. + + + + true + $(FileBasedAppArtifactsPath) + false + <_ArtifactsPathLocationType>FileBasedApp + diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs index 27debf9ca339..78f30ac39bf5 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs @@ -919,6 +919,67 @@ public void ArtifactsDirectory_Permissions() .Should().Be(actualMode, artifactsDir); } + [TestMethod] + public void ArtifactsPath() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, s_program); + + var globalArtifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(globalArtifactsDir)) Directory.Delete(globalArtifactsDir, recursive: true); + + new DirectoryInfo(Path.Join(testInstance.Path, "artifacts")).Should().NotExist(); + new DirectoryInfo(Path.Join(testInstance.Path, "bin")).Should().NotExist(); + + new DotnetCommand(Log, "build", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + new DirectoryInfo(globalArtifactsDir).EnumerateDirectories().Should().NotBeEmpty(); + new FileInfo(Path.Join(globalArtifactsDir, "bin", "debug", "Program.dll")).Should().Exist(); + new DirectoryInfo(Path.Join(globalArtifactsDir, "bin")).EnumerateDirectories().Select(d => d.Name).Should().BeEquivalentTo(["debug"]); + new DirectoryInfo(Path.Join(globalArtifactsDir, "artifacts")).Should().NotExist(); + + new DirectoryInfo(Path.Join(testInstance.Path, "artifacts")).Should().NotExist(); + new DirectoryInfo(Path.Join(testInstance.Path, "bin")).Should().NotExist(); + } + + /// + /// When the surrounding repo uses artifacts layout, file-based apps place their artifacts there. + /// + [TestMethod] + public void ArtifactsPath_ReusedFromRepo() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, s_program); + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ + + + true + + + """); + + var globalArtifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(globalArtifactsDir)) Directory.Delete(globalArtifactsDir, recursive: true); + + new DirectoryInfo(Path.Join(testInstance.Path, "artifacts")).Should().NotExist(); + + new DotnetCommand(Log, "build", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // We still put our marker files into the global artifacts directory, but it should not contain any subdirectories. + new DirectoryInfo(globalArtifactsDir).EnumerateDirectories().Should().BeEmpty(); + + new FileInfo(Path.Join(testInstance.Path, "artifacts", "bin", "Program.cs", "debug", "Program.dll")).Should().Exist(); + new DirectoryInfo(Path.Join(testInstance.Path, "artifacts", "bin", "debug")).Should().NotExist(); + } + [TestMethod, CombinatorialData] public void LaunchProfile( bool cscOnly, diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs index debafda15758..7b696b3f31ae 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs @@ -1390,8 +1390,7 @@ public void Api() - false - /artifacts + /artifacts Program $(AssemblyName) artifacts/$(AssemblyName) @@ -1475,8 +1474,7 @@ public void Api_Evaluation() - false - /artifacts + /artifacts A $(AssemblyName) artifacts/$(AssemblyName) @@ -1551,8 +1549,7 @@ public void Api_Diagnostic_01() - false - /artifacts + /artifacts Program $(AssemblyName) artifacts/$(AssemblyName) @@ -1626,8 +1623,7 @@ public void Api_Diagnostic_02() - false - /artifacts + /artifacts Program $(AssemblyName) artifacts/$(AssemblyName) From 7a579f3f0a2dab5667e772b83f1d25d532ed4d34 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 10 Aug 2026 14:55:42 +0200 Subject: [PATCH 2/4] Place publish and pack outputs to shared artifacts too --- documentation/general/dotnet-run-file.md | 3 +- .../VirtualProjectBuilder.cs | 2 -- .../Microsoft.NET.DefaultArtifactsPath.props | 4 +++ .../Run/RunFileTests_BuildCommands.cs | 33 +++++++++++++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index a56ce6273374..6be1b9b74b3c 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -43,7 +43,8 @@ Additionally, the implicit project file has the following customizations: - `ArtifactsPath` is set to a [temp directory](#build-outputs), unless [artifacts output layout][artifacts-output] is enabled. - - `PublishDir` and `PackageOutputPath` are set to `./artifacts/` so the outputs of `dotnet publish` and `dotnet pack` are next to the file-based app. + - `PublishDir` and `PackageOutputPath` are set to `./artifacts/` so the outputs of `dotnet publish` and `dotnet pack` are next to the file-based app, + unless [artifacts output layout][artifacts-output] is enabled. - `RuntimeHostConfigurationOption`s are set for `EntryPointFilePath` and `EntryPointFileDirectoryPath` (except for `Publish` and `Pack` targets) which can be accessed in the app via `AppContext`: diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs index 7f70e4326340..fb63d848592b 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs @@ -638,8 +638,6 @@ internal static void WriteProjectFile( {EscapeValue(artifactsPath)} {EscapeValue(Path.GetFileNameWithoutExtension(entryPointFilePath))} $(AssemblyName) - artifacts/$(AssemblyName) - artifacts/$(AssemblyName) true {EscapeValue(entryPointFilePath)} {CSharpDirective.IncludeOrExclude.DefaultMappingString} diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props index 8deceb4224e3..e6bb33eb5801 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props @@ -56,5 +56,9 @@ Copyright (c) .NET Foundation. All rights reserved. $(FileBasedAppArtifactsPath) false <_ArtifactsPathLocationType>FileBasedApp + + + artifacts/$(AssemblyName) + artifacts/$(AssemblyName) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs index 78f30ac39bf5..08ae17b738b0 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs @@ -966,7 +966,8 @@ public void ArtifactsPath_ReusedFromRepo() var globalArtifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); if (Directory.Exists(globalArtifactsDir)) Directory.Delete(globalArtifactsDir, recursive: true); - new DirectoryInfo(Path.Join(testInstance.Path, "artifacts")).Should().NotExist(); + var localArtifactsDir = Path.Join(testInstance.Path, "artifacts"); + new DirectoryInfo(localArtifactsDir).Should().NotExist(); new DotnetCommand(Log, "build", "Program.cs") .WithWorkingDirectory(testInstance.Path) @@ -976,8 +977,34 @@ public void ArtifactsPath_ReusedFromRepo() // We still put our marker files into the global artifacts directory, but it should not contain any subdirectories. new DirectoryInfo(globalArtifactsDir).EnumerateDirectories().Should().BeEmpty(); - new FileInfo(Path.Join(testInstance.Path, "artifacts", "bin", "Program.cs", "debug", "Program.dll")).Should().Exist(); - new DirectoryInfo(Path.Join(testInstance.Path, "artifacts", "bin", "debug")).Should().NotExist(); + new FileInfo(Path.Join(localArtifactsDir, "bin", "Program.cs", "debug", "Program.dll")).Should().Exist(); + new DirectoryInfo(Path.Join(localArtifactsDir, "bin", "debug")).Should().NotExist(); + + // Publish + + Directory.Delete(Path.Join(localArtifactsDir), recursive: true); + + new DotnetCommand(Log, "publish", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + new DirectoryInfo(globalArtifactsDir).EnumerateDirectories().Should().BeEmpty(); + new DirectoryInfo(localArtifactsDir).EnumerateDirectories().Select(d => d.Name).Should().BeEquivalentTo(["bin", "obj", "publish"]); + new FileInfo(Path.Join(localArtifactsDir, "publish", "Program.cs", "release", $"Program{Constants.ExeSuffix}")).Should().Exist(); + + // Pack + + Directory.Delete(localArtifactsDir, recursive: true); + + new DotnetCommand(Log, "pack", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + new DirectoryInfo(globalArtifactsDir).EnumerateDirectories().Should().BeEmpty(); + new DirectoryInfo(localArtifactsDir).EnumerateDirectories().Select(d => d.Name).Should().BeEquivalentTo(["bin", "obj", "package", "publish"]); + new FileInfo(Path.Join(localArtifactsDir, "package", "release", "Program.1.0.0.nupkg")).Should().Exist(); } [TestMethod, CombinatorialData] From 07176cbb86395fea8d78727465677bddc6c1dc3d Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 11 Aug 2026 10:41:36 +0200 Subject: [PATCH 3/4] Fixup Api tests --- .../CommandTests/Run/RunFileTests_CscOnlyAndApi.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs index 7b696b3f31ae..b8b4735ae5c8 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs @@ -1393,8 +1393,6 @@ public void Api() /artifacts Program $(AssemblyName) - artifacts/$(AssemblyName) - artifacts/$(AssemblyName) true {programPath} .cs=Compile;.resx=EmbeddedResource;.json=None;.razor=Content;.dll=Reference @@ -1477,8 +1475,6 @@ public void Api_Evaluation() /artifacts A $(AssemblyName) - artifacts/$(AssemblyName) - artifacts/$(AssemblyName) true {programPath} .cs=Compile;.resx=EmbeddedResource;.json=None;.razor=Content;.dll=Reference @@ -1552,8 +1548,6 @@ public void Api_Diagnostic_01() /artifacts Program $(AssemblyName) - artifacts/$(AssemblyName) - artifacts/$(AssemblyName) true {programPath} .cs=Compile;.resx=EmbeddedResource;.json=None;.razor=Content;.dll=Reference @@ -1626,8 +1620,6 @@ public void Api_Diagnostic_02() /artifacts Program $(AssemblyName) - artifacts/$(AssemblyName) - artifacts/$(AssemblyName) true {programPath} .cs=Compile;.resx=EmbeddedResource;.json=None;.razor=Content;.dll=Reference From 91a2058006b08ad32096e2d9179ccdf3bf59aabe Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 11 Aug 2026 10:53:21 +0200 Subject: [PATCH 4/4] Fix source root --- .../Microsoft.NET.DefaultArtifactsPath.props | 18 ++++++------- .../Run/RunFileTests_BuildCommands.cs | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props index e6bb33eb5801..c76932819540 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.DefaultArtifactsPath.props @@ -41,15 +41,6 @@ Copyright (c) .NET Foundation. All rights reserved. <_ArtifactsPathLocationType>ProjectFolder - - - <_CanonicalizedArtifactsPath Condition="'$(ArtifactsPath)' != ''">$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetFullPath('$(ArtifactsPath)')))) - - - - - true @@ -61,4 +52,13 @@ Copyright (c) .NET Foundation. All rights reserved. artifacts/$(AssemblyName) artifacts/$(AssemblyName) + + + + <_CanonicalizedArtifactsPath Condition="'$(ArtifactsPath)' != ''">$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetFullPath('$(ArtifactsPath)')))) + + + + diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs index 08ae17b738b0..f493602c273e 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs @@ -946,6 +946,33 @@ public void ArtifactsPath() new DirectoryInfo(Path.Join(testInstance.Path, "bin")).Should().NotExist(); } + [TestMethod] + public void ArtifactsPath_IsAddedAsSourceRoot() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + var programPath = Path.Join(testInstance.Path, "Program.cs"); + var sourceRootsPath = Path.Join(testInstance.Path, "source-roots.txt"); + File.WriteAllText(programPath, s_program); + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ + + + + + + """); + + new DotnetCommand(Log, "build", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + var expectedSourceRoot = VirtualProjectBuilder.GetArtifactsPath(programPath) + Path.DirectorySeparatorChar; + File.ReadAllLines(sourceRootsPath).Should().Contain( + sourceRoot => sourceRoot.Equals(expectedSourceRoot, StringComparison.OrdinalIgnoreCase)); + } + /// /// When the surrounding repo uses artifacts layout, file-based apps place their artifacts there. ///