about summary refs log tree commit diff
diff options
context:
space:
mode:
authorIvan Trubach <mr.trubach@icloud.com>2024-05-16 02:10:39 +0300
committerIvan Trubach <mr.trubach@icloud.com>2024-05-29 08:45:25 +0300
commitf7d8046fb83cf0bb4e0a8ad179dee89bcbe0335d (patch)
tree9b69ac9e898c2c948da20b47fbeab9fdbea5cfa8
parent22a5fc0518c6b323d98b052ae6a14fd010808d1f (diff)
buildDotnetModule: fix structured attributes support
This change refactors internal hooks used by buildDotnetModule to
support derivations with structured attributes. Note that this changes
variable names that the internal hooks expect.
-rw-r--r--pkgs/applications/misc/ArchiSteamFarm/default.nix2
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-global-tool/default.nix2
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/default.nix36
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/hooks/default.nix36
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-build-hook.sh76
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-check-hook.sh62
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-configure-hook.sh98
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-fixup-hook.sh81
-rw-r--r--pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-install-hook.sh80
-rw-r--r--pkgs/test/dotnet/default.nix4
-rw-r--r--pkgs/test/dotnet/project-references/default.nix10
-rw-r--r--pkgs/test/dotnet/structured-attrs/default.nix36
-rw-r--r--pkgs/test/dotnet/structured-attrs/nuget-deps.nix5
-rw-r--r--pkgs/test/dotnet/structured-attrs/src/Application.cs10
-rw-r--r--pkgs/test/dotnet/structured-attrs/src/Application.csproj5
-rw-r--r--pkgs/test/dotnet/use-dotnet-from-env/default.nix60
-rw-r--r--pkgs/test/dotnet/use-dotnet-from-env/nuget-deps.nix5
-rw-r--r--pkgs/test/dotnet/use-dotnet-from-env/src/Application.cs3
-rw-r--r--pkgs/test/dotnet/use-dotnet-from-env/src/Application.csproj5
19 files changed, 440 insertions, 176 deletions
diff --git a/pkgs/applications/misc/ArchiSteamFarm/default.nix b/pkgs/applications/misc/ArchiSteamFarm/default.nix
index 7c334fb804558..fc3335ca3a927 100644
--- a/pkgs/applications/misc/ArchiSteamFarm/default.nix
+++ b/pkgs/applications/misc/ArchiSteamFarm/default.nix
@@ -41,7 +41,7 @@ buildDotnetModule rec {
   doCheck = true;
 
   preBuild = ''
-    export projectFile=(ArchiSteamFarm)
+    dotnetProjectFiles=(ArchiSteamFarm)
   '';
 
   preInstall = ''
diff --git a/pkgs/build-support/dotnet/build-dotnet-global-tool/default.nix b/pkgs/build-support/dotnet/build-dotnet-global-tool/default.nix
index 16cf029ca3451..7ae9cfc9f6618 100644
--- a/pkgs/build-support/dotnet/build-dotnet-global-tool/default.nix
+++ b/pkgs/build-support/dotnet/build-dotnet-global-tool/default.nix
@@ -28,7 +28,7 @@ buildDotnetModule (args // {
     ] ++ (nugetDeps fetchNuGet);
   };
 
-  projectFile = "";
+  dotnetGlobalTool = true;
 
   useDotnetFromEnv = true;
 
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/default.nix b/pkgs/build-support/dotnet/build-dotnet-module/default.nix
index e2ddee48cc006..6a4f9129cd44d 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/default.nix
+++ b/pkgs/build-support/dotnet/build-dotnet-module/default.nix
@@ -69,7 +69,7 @@
 , disabledTests ? [ ]
   # The project file to run unit tests against. This is usually referenced in the regular project file, but sometimes it needs to be manually set.
   # It gets restored and build, but not installed. You may need to regenerate your nuget lockfile after setting this.
-, testProjectFile ? ""
+, testProjectFile ? null
 
   # The type of build to perform. This is passed to `dotnet` with the `--configuration` flag. Possible values are `Release`, `Debug`, etc.
 , buildType ? "Release"
@@ -88,17 +88,18 @@
 } @ args:
 
 let
+  projectFiles =
+    lib.optionals (projectFile != null) (lib.toList projectFile);
+  testProjectFiles =
+    lib.optionals (testProjectFile != null) (lib.toList testProjectFile);
+
   platforms =
     if args ? meta.platforms
     then lib.intersectLists args.meta.platforms dotnet-sdk.meta.platforms
     else dotnet-sdk.meta.platforms;
 
   inherit (callPackage ./hooks {
-    inherit dotnet-sdk disabledTests nuget-source dotnet-runtime runtimeDeps buildType;
-    runtimeId =
-      if runtimeId != null
-      then runtimeId
-      else dotnetCorePackages.systemToDotnetRid stdenvNoCC.targetPlatform.system;
+    inherit dotnet-sdk dotnet-runtime;
   }) dotnetConfigureHook dotnetBuildHook dotnetCheckHook dotnetInstallHook dotnetFixupHook;
 
   localDeps =
@@ -143,6 +144,19 @@ let
   nugetDepsFile = _nugetDeps.sourceFile;
 in
 stdenvNoCC.mkDerivation (args // {
+  dotnetInstallPath = installPath;
+  dotnetExecutables = executables;
+  dotnetBuildType = buildType;
+  dotnetProjectFiles = projectFiles;
+  dotnetTestProjectFiles = testProjectFiles;
+  dotnetDisabledTests = disabledTests;
+  dotnetRuntimeId = runtimeId;
+  nugetSource = nuget-source;
+  dotnetRuntimeDeps = map lib.getLib runtimeDeps;
+  dotnetSelfContainedBuild = selfContainedBuild;
+  dotnetUseAppHost = useAppHost;
+  inherit useDotnetFromEnv;
+
   nativeBuildInputs = args.nativeBuildInputs or [ ] ++ [
     dotnetConfigureHook
     dotnetBuildHook
@@ -172,7 +186,7 @@ stdenvNoCC.mkDerivation (args // {
        else [ ]));
 
   makeWrapperArgs = args.makeWrapperArgs or [ ] ++ [
-    "--prefix LD_LIBRARY_PATH : ${dotnet-sdk.icu}/lib"
+    "--prefix" "LD_LIBRARY_PATH" ":" "${dotnet-sdk.icu}/lib"
   ];
 
   # Stripping breaks the executable
@@ -181,8 +195,6 @@ stdenvNoCC.mkDerivation (args // {
   # gappsWrapperArgs gets included when wrapping for dotnet, as to avoid double wrapping
   dontWrapGApps = args.dontWrapGApps or true;
 
-  inherit selfContainedBuild useAppHost useDotnetFromEnv;
-
   # propagate the runtime sandbox profile since the contents apply to published
   # executables
   propagatedSandboxProfile = toString dotnet-runtime.__propagatedSandboxProfile;
@@ -267,11 +279,11 @@ stdenvNoCC.mkDerivation (args // {
                 --no-cache \
                 --force \
                 ${lib.optionalString (!enableParallelBuilding) "--disable-parallel"} \
-                ${lib.optionalString (flags != []) (toString flags)}
+                ${lib.escapeShellArgs flags}
         }
 
-        declare -a projectFiles=( ${toString (lib.toList projectFile)} )
-        declare -a testProjectFiles=( ${toString (lib.toList testProjectFile)} )
+        declare -a projectFiles=( ${lib.escapeShellArgs projectFiles} )
+        declare -a testProjectFiles=( ${lib.escapeShellArgs testProjectFiles} )
 
         export DOTNET_NOLOGO=1
         export DOTNET_CLI_TELEMETRY_OPTOUT=1
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/hooks/default.nix b/pkgs/build-support/dotnet/build-dotnet-module/hooks/default.nix
index 44091604f5c2c..b9c51a743c6a6 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/hooks/default.nix
+++ b/pkgs/build-support/dotnet/build-dotnet-module/hooks/default.nix
@@ -4,28 +4,21 @@
 , coreutils
 , zlib
 , openssl
-, callPackage
 , makeSetupHook
-, makeWrapper
+, dotnetCorePackages
+  # Passed from ../default.nix
 , dotnet-sdk
-, disabledTests
-, nuget-source
 , dotnet-runtime
-, runtimeDeps
-, buildType
-, runtimeId
 }:
-assert (builtins.isString runtimeId);
-
 let
-  libraryPath = lib.makeLibraryPath runtimeDeps;
+  runtimeId = dotnetCorePackages.systemToDotnetRid stdenv.hostPlatform.system;
 in
 {
   dotnetConfigureHook = makeSetupHook
     {
       name = "dotnet-configure-hook";
       substitutions = {
-        nugetSource = nuget-source;
+        runtimeId = lib.escapeShellArg runtimeId;
         dynamicLinker = "${stdenv.cc}/nix-support/dynamic-linker";
         libPath = lib.makeLibraryPath [
           stdenv.cc.cc.lib
@@ -34,7 +27,6 @@ in
           zlib
           openssl
         ];
-        inherit runtimeId;
       };
     }
     ./dotnet-configure-hook.sh;
@@ -43,7 +35,7 @@ in
     {
       name = "dotnet-build-hook";
       substitutions = {
-        inherit buildType runtimeId;
+        runtimeId = lib.escapeShellArg runtimeId;
       };
     }
     ./dotnet-build-hook.sh;
@@ -52,15 +44,7 @@ in
     {
       name = "dotnet-check-hook";
       substitutions = {
-        inherit buildType runtimeId libraryPath;
-        disabledTests = lib.optionalString (disabledTests != [ ])
-          (
-            let
-              escapedNames = lib.lists.map (n: lib.replaceStrings [ "," ] [ "%2C" ] n) disabledTests;
-              filters = lib.lists.map (n: "FullyQualifiedName!=${n}") escapedNames;
-            in
-            "${lib.concatStringsSep "&" filters}"
-          );
+        runtimeId = lib.escapeShellArg runtimeId;
       };
     }
     ./dotnet-check-hook.sh;
@@ -69,7 +53,7 @@ in
     {
       name = "dotnet-install-hook";
       substitutions = {
-        inherit buildType runtimeId;
+        runtimeId = lib.escapeShellArg runtimeId;
       };
     }
     ./dotnet-install-hook.sh;
@@ -79,11 +63,7 @@ in
       name = "dotnet-fixup-hook";
       substitutions = {
         dotnetRuntime = dotnet-runtime;
-        runtimeDeps = libraryPath;
-        shell = stdenv.shell;
-        which = "${which}/bin/which";
-        dirname = "${coreutils}/bin/dirname";
-        realpath = "${coreutils}/bin/realpath";
+        wrapperPath = lib.makeBinPath [ which coreutils ];
       };
     }
     ./dotnet-fixup-hook.sh;
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-build-hook.sh b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-build-hook.sh
index 798109291f92a..f209861f79b15 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-build-hook.sh
+++ b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-build-hook.sh
@@ -1,12 +1,25 @@
-# inherit arguments from derivation
-dotnetBuildFlags=( ${dotnetBuildFlags[@]-} )
-
 dotnetBuildHook() {
     echo "Executing dotnetBuildHook"
 
     runHook preBuild
 
-    if [ "${enableParallelBuilding-}" ]; then
+    local -r hostRuntimeId=@runtimeId@
+    local -r dotnetBuildType="${dotnetBuildType-Release}"
+    local -r dotnetRuntimeId="${dotnetRuntimeId-$hostRuntimeId}"
+
+    if [[ -n $__structuredAttrs ]]; then
+        local dotnetProjectFilesArray=( "${dotnetProjectFiles[@]}" )
+        local dotnetTestProjectFilesArray=( "${dotnetTestProjectFiles[@]}" )
+        local dotnetFlagsArray=( "${dotnetFlags[@]}" )
+        local dotnetBuildFlagsArray=( "${dotnetBuildFlags[@]}" )
+    else
+        local dotnetProjectFilesArray=($dotnetProjectFiles)
+        local dotnetTestProjectFilesArray=($dotnetTestProjectFiles)
+        local dotnetFlagsArray=($dotnetFlags)
+        local dotnetBuildFlagsArray=($dotnetBuildFlags)
+    fi
+
+    if [[ -n "${enableParallelBuilding-}" ]]; then
         local -r maxCpuFlag="$NIX_BUILD_CORES"
         local -r parallelBuildFlag="true"
     else
@@ -14,50 +27,53 @@ dotnetBuildHook() {
         local -r parallelBuildFlag="false"
     fi
 
-    if [ "${selfContainedBuild-}" ]; then
-        dotnetBuildFlags+=("-p:SelfContained=true")
+    if [[ -n ${dotnetSelfContainedBuild-} ]]; then
+        dotnetBuildFlagsArray+=("-p:SelfContained=true")
     else
-        dotnetBuildFlags+=("-p:SelfContained=false")
+        dotnetBuildFlagsArray+=("-p:SelfContained=false")
     fi
 
-    if [ "${useAppHost-}" ]; then
-        dotnetBuildFlags+=("-p:UseAppHost=true")
+    if [[ -n ${dotnetUseAppHost-} ]]; then
+        dotnetBuildFlagsArray+=("-p:UseAppHost=true")
     fi
 
-    local versionFlags=()
-    if [ "${version-}" ]; then
-        versionFlags+=("-p:InformationalVersion=${version-}")
+    local versionFlagsArray=()
+    if [[ -n ${version-} ]]; then
+        versionFlagsArray+=("-p:InformationalVersion=$version")
     fi
 
-    if [ "${versionForDotnet-}" ]; then
-        versionFlags+=("-p:Version=${versionForDotnet-}")
+    if [[ -n ${versionForDotnet-} ]]; then
+        versionFlagsArray+=("-p:Version=$versionForDotnet")
     fi
 
     dotnetBuild() {
-        local -r project="${1-}"
+        local -r projectFile="${1-}"
 
-        runtimeIdFlags=()
-        if [[ "$project" == *.csproj ]] || [ "${selfContainedBuild-}" ]; then
-            runtimeIdFlags+=("--runtime @runtimeId@")
+        local runtimeIdFlagsArray=()
+        if [[ $projectFile == *.csproj || -n ${dotnetSelfContainedBuild-} ]]; then
+            runtimeIdFlagsArray+=("--runtime" "$dotnetRuntimeId")
         fi
 
-        dotnet build ${project-} \
-            -maxcpucount:$maxCpuFlag \
-            -p:BuildInParallel=$parallelBuildFlag \
+        dotnet build ${1+"$projectFile"} \
+            -maxcpucount:"$maxCpuFlag" \
+            -p:BuildInParallel="$parallelBuildFlag" \
             -p:ContinuousIntegrationBuild=true \
             -p:Deterministic=true \
-            --configuration "@buildType@" \
+            --configuration "$dotnetBuildType" \
             --no-restore \
-            ${versionFlags[@]} \
-            ${runtimeIdFlags[@]} \
-            ${dotnetBuildFlags[@]}  \
-            ${dotnetFlags[@]}
+            "${versionFlagsArray[@]}" \
+            "${runtimeIdFlagsArray[@]}" \
+            "${dotnetBuildFlagsArray[@]}" \
+            "${dotnetFlagsArray[@]}"
     }
 
-    (( "${#projectFile[@]}" == 0 )) && dotnetBuild
+    if (( ${#dotnetProjectFilesArray[@]} == 0 )); then
+        dotnetBuild
+    fi
 
-    for project in ${projectFile[@]} ${testProjectFile[@]-}; do
-        dotnetBuild "$project"
+    local projectFile
+    for projectFile in "${dotnetProjectFilesArray[@]}" "${dotnetTestProjectFilesArray[@]}"; do
+        dotnetBuild "$projectFile"
     done
 
     runHook postBuild
@@ -65,6 +81,6 @@ dotnetBuildHook() {
     echo "Finished dotnetBuildHook"
 }
 
-if [[ -z "${dontDotnetBuild-}" && -z "${buildPhase-}" ]]; then
+if [[ -z ${dontDotnetBuild-} && -z ${buildPhase-} ]]; then
     buildPhase=dotnetBuildHook
 fi
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-check-hook.sh b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-check-hook.sh
index f19bf9f620feb..c91251f4f1807 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-check-hook.sh
+++ b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-check-hook.sh
@@ -1,39 +1,65 @@
-# inherit arguments from derivation
-dotnetTestFlags=( ${dotnetTestFlags[@]-} )
-
 dotnetCheckHook() {
     echo "Executing dotnetCheckHook"
 
     runHook preCheck
 
-    if [ "${disabledTests-}" ]; then
-        local -r disabledTestsFlag="--filter @disabledTests@"
+    local -r hostRuntimeId=@runtimeId@
+    local -r dotnetBuildType="${dotnetBuildType-Release}"
+    local -r dotnetRuntimeId="${dotnetRuntimeId-$hostRuntimeId}"
+
+    if [[ -n $__structuredAttrs ]]; then
+        local dotnetProjectFilesArray=( "${dotnetProjectFiles[@]}" )
+        local dotnetTestProjectFilesArray=( "${dotnetTestProjectFiles[@]}" )
+        local dotnetTestFlagsArray=( "${dotnetTestFlags[@]}" )
+        local dotnetDisabledTestsArray=( "${dotnetDisabledTests[@]}" )
+        local dotnetRuntimeDepsArray=( "${dotnetRuntimeDeps[@]}" )
+    else
+        local dotnetProjectFilesArray=($dotnetProjectFiles)
+        local dotnetTestProjectFilesArray=($dotnetTestProjectFiles)
+        local dotnetTestFlagsArray=($dotnetTestFlags)
+        local dotnetDisabledTestsArray=($dotnetDisabledTests)
+        local dotnetRuntimeDepsArray=($dotnetRuntimeDeps)
+    fi
+
+    if (( ${#dotnetDisabledTestsArray[@]} > 0 )); then
+        local disabledTestsFilters=("${dotnetDisabledTestsArray[@]/#/FullyQualifiedName!=}")
+        local OLDIFS="$IFS" IFS='&'
+        dotnetTestFlagsArray+=("--filter:${disabledTestsFilters[*]//,/%2C}")
+        IFS="$OLDIFS"
+    fi
+
+    local libraryPath="${LD_LIBRARY_PATH-}"
+    if (( ${#dotnetRuntimeDepsArray[@]} > 0 )); then
+        local libraryPathArray=("${dotnetRuntimeDepsArray[@]/%//lib}")
+        local OLDIFS="$IFS" IFS=':'
+        libraryPath="${libraryPathArray[*]}${libraryPath:+':'}$libraryPath"
+        IFS="$OLDIFS"
     fi
 
-    if [ "${enableParallelBuilding-}" ]; then
+    if [[ -n ${enableParallelBuilding-} ]]; then
         local -r maxCpuFlag="$NIX_BUILD_CORES"
     else
         local -r maxCpuFlag="1"
     fi
 
-    for project in ${testProjectFile[@]-${projectFile[@]}}; do
-        runtimeIdFlags=()
-        if [[ "$project" == *.csproj ]]; then
-            runtimeIdFlags=("--runtime @runtimeId@")
+    local projectFile
+    for projectFile in "${dotnetTestProjectFilesArray[@]-${dotnetProjectFilesArray[@]}}"; do
+        local runtimeIdFlagsArray=()
+        if [[ $projectFile == *.csproj ]]; then
+            runtimeIdFlagsArray=("--runtime" "$dotnetRuntimeId")
         fi
 
-        LD_LIBRARY_PATH="@libraryPath@" \
-            dotnet test "$project" \
-              -maxcpucount:$maxCpuFlag \
+        LD_LIBRARY_PATH=$libraryPath \
+            dotnet test "$projectFile" \
+              -maxcpucount:"$maxCpuFlag" \
               -p:ContinuousIntegrationBuild=true \
               -p:Deterministic=true \
-              --configuration "@buildType@" \
+              --configuration "$dotnetBuildType" \
               --no-build \
               --logger "console;verbosity=normal" \
-              ${disabledTestsFlag-} \
-              ${runtimeIdFlags[@]} \
-              "${dotnetTestFlags[@]}"  \
-              "${dotnetFlags[@]}"
+              "${runtimeIdFlagsArray[@]}" \
+              "${dotnetTestFlagsArray[@]}" \
+              "${dotnetFlagsArray[@]}"
     done
 
     runHook postCheck
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-configure-hook.sh b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-configure-hook.sh
index 3eb0d4e1f2309..12fa348699865 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-configure-hook.sh
+++ b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-configure-hook.sh
@@ -1,63 +1,103 @@
-declare -a projectFile testProjectFile
-
-# Inherit arguments from derivation
-dotnetFlags=( ${dotnetFlags[@]-} )
-dotnetRestoreFlags=( ${dotnetRestoreFlags[@]-} )
-
 dotnetConfigureHook() {
     echo "Executing dotnetConfigureHook"
 
     runHook preConfigure
 
-    if [ -z "${enableParallelBuilding-}" ]; then
+    if [[ -z ${nugetSource-} ]]; then
+        echo
+        echo "ERROR: no dependencies were specified"
+        echo 'Hint: set `nugetSource` if using these hooks individually. If this is happening with `buildDotnetModule`, please open an issue.'
+        echo
+
+        exit 1
+    fi
+
+    local nugetSourceSedQuoted="${nugetSource//[\/\\&$'\n']/\\&}"
+    local nugetSourceXMLQuoted="$nugetSource"
+    nugetSourceXMLQuoted="${nugetSource//&/\&amp;}"
+    nugetSourceXMLQuoted="${nugetSourceXMLQuoted//\"/\&quot;}"
+
+    local -r hostRuntimeId=@runtimeId@
+    local -r dynamicLinker=@dynamicLinker@
+    local -r libPath=@libPath@
+    local -r dotnetRuntimeId="${dotnetRuntimeId-$hostRuntimeId}"
+
+    if [[ -n $__structuredAttrs ]]; then
+        local dotnetProjectFilesArray=( "${dotnetProjectFiles[@]}" )
+        local dotnetTestProjectFilesArray=( "${dotnetTestProjectFiles[@]}" )
+        local dotnetFlagsArray=( "${dotnetFlags[@]}" )
+        local dotnetRestoreFlagsArray=( "${dotnetRestoreFlags[@]}" )
+    else
+        local dotnetProjectFilesArray=($dotnetProjectFiles)
+        local dotnetTestProjectFilesArray=($dotnetTestProjectFiles)
+        local dotnetFlagsArray=($dotnetFlags)
+        local dotnetRestoreFlagsArray=($dotnetRestoreFlags)
+    fi
+
+    if [[ -z ${enableParallelBuilding-} ]]; then
         local -r parallelFlag="--disable-parallel"
     fi
 
     dotnetRestore() {
-        local -r project="${1-}"
-        dotnet restore ${project-} \
+        local -r projectFile="${1-}"
+        dotnet restore ${1+"$projectFile"} \
             -p:ContinuousIntegrationBuild=true \
             -p:Deterministic=true \
-            --runtime "@runtimeId@" \
-            --source "@nugetSource@/lib" \
+            --runtime "$dotnetRuntimeId" \
+            --source "$nugetSource/lib" \
             ${parallelFlag-} \
-            ${dotnetRestoreFlags[@]} \
-            ${dotnetFlags[@]}
+            "${dotnetRestoreFlagsArray[@]}" \
+            "${dotnetFlagsArray[@]}"
     }
 
     # Generate a NuGet.config file to make sure everything,
     # including things like <Sdk /> dependencies, is restored from the proper source
-cat <<EOF > "./NuGet.config"
+    cat >NuGet.config <<EOF
 <?xml version="1.0" encoding="utf-8"?>
 <configuration>
   <packageSources>
     <clear />
-    <add key="nugetSource" value="@nugetSource@/lib" />
+    <add key="nugetSource" value="$nugetSourceXMLQuoted/lib" />
   </packageSources>
 </configuration>
 EOF
 
-    # Patch paket.dependencies and paket.lock (if found) to use the proper source. This ensures
-    # paket restore works correctly
-    # We use + instead of / in sed to avoid problems with slashes
-    find -name paket.dependencies -exec sed -i 's+source .*+source @nugetSource@/lib+g' {} \;
-    find -name paket.lock -exec sed -i 's+remote:.*+remote: @nugetSource@/lib+g' {} \;
-
-    dotnet tool restore --add-source "@nugetSource@/lib"
-
-    (( "${#projectFile[@]}" == 0 )) && dotnetRestore
+    # Patch paket.dependencies and paket.lock (if found) to use the proper
+    # source. This ensures paket restore works correctly. Note that the
+    # nugetSourceSedQuoted abomination below safely escapes nugetSource string
+    # for use as a sed replacement string to avoid issues with slashes and other
+    # special characters ('&', '\\' and '\n').
+    find -name paket.dependencies -exec sed -i "s/source .*/source $nugetSourceSedQuoted\/lib/g" {} \;
+    find -name paket.lock -exec sed -i "s/remote:.*/remote: $nugetSourceSedQuoted\/lib/g" {} \;
+
+    dotnet tool restore --add-source "$nugetSource/lib"
+
+    # dotnetGlobalTool is set in buildDotnetGlobalTool to patch dependencies but
+    # avoid other project-specific logic. This is a hack, but the old behavior
+    # is worse as it relied on a bug: setting projectFile to an empty string
+    # made the hooks actually skip all project-specific logic. It’s hard to keep
+    # backwards compatibility with this odd behavior now since we are using
+    # arrays, so instead we just pass a variable to indicate that we don’t have
+    # projects.
+    if [[ -z ${dotnetGlobalTool-} ]]; then
+        if (( ${#dotnetProjectFilesArray[@]} == 0 )); then
+            dotnetRestore
+        fi
 
-    for project in ${projectFile[@]} ${testProjectFile[@]-}; do
-        dotnetRestore "$project"
-    done
+        local projectFile
+        for projectFile in "${dotnetProjectFilesArray[@]}" "${dotnetTestProjectFilesArray[@]}"; do
+            dotnetRestore "$projectFile"
+        done
+    fi
 
     echo "Fixing up native binaries..."
     # Find all native binaries and nuget libraries, and fix them up,
     # by setting the proper interpreter and rpath to some commonly used libraries
+    local binary
     for binary in $(find "$HOME/.nuget/packages/" -type f -executable); do
         if patchelf --print-interpreter "$binary" >/dev/null 2>/dev/null; then
             echo "Found binary: $binary, fixing it up..."
-            patchelf --set-interpreter "$(cat "@dynamicLinker@")" "$binary"
+            patchelf --set-interpreter "$(cat "$dynamicLinker")" "$binary"
 
             # This makes sure that if the binary requires some specific runtime dependencies, it can find it.
             # This fixes dotnet-built binaries like crossgen2
@@ -68,7 +108,7 @@ EOF
                 --add-needed libssl.so \
                 "$binary"
 
-            patchelf --set-rpath "@libPath@" "$binary"
+            patchelf --set-rpath "$libPath" "$binary"
         fi
     done
 
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-fixup-hook.sh b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-fixup-hook.sh
index e3671728af35e..f9aba29a43555 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-fixup-hook.sh
+++ b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-fixup-hook.sh
@@ -1,28 +1,55 @@
-# Inherit arguments from the derivation
-declare -a derivationMakeWrapperArgs="( ${makeWrapperArgs-} )"
-makeWrapperArgs=( "${derivationMakeWrapperArgs[@]}" )
-
 # First argument is the executable you want to wrap,
 # the second is the destination for the wrapper.
 wrapDotnetProgram() {
-    local dotnetRootFlags=()
+    local -r dotnetRuntime=@dotnetRuntime@
+    local -r wrapperPath=@wrapperPath@
+
+    local -r dotnetFromEnvScript='dotnetFromEnv() {
+    local dotnetPath
+    if command -v dotnet 2>&1 >/dev/null; then
+        dotnetPath=$(which dotnet) && \
+            dotnetPath=$(realpath "$dotnetPath") && \
+            dotnetPath=$(dirname "$dotnetPath") && \
+            export DOTNET_ROOT="$dotnetPath"
+    fi
+}
+dotnetFromEnv'
+
+    if [[ -n $__structuredAttrs ]]; then
+        local -r dotnetRuntimeDepsArray=( "${dotnetRuntimeDeps[@]}" )
+        local -r makeWrapperArgsArray=( "${makeWrapperArgs[@]}" )
+    else
+        local -r dotnetRuntimeDepsArray=($dotnetRuntimeDeps)
+        local -r makeWrapperArgsArray=($makeWrapperArgs)
+    fi
 
-    if [ ! "${selfContainedBuild-}" ]; then
-        if [ "${useDotnetFromEnv-}" ]; then
+    local dotnetRuntimeDepsFlags=()
+    if (( ${#dotnetRuntimeDepsArray[@]} > 0 )); then
+        local libraryPathArray=("${dotnetRuntimeDepsArray[@]/%//lib}")
+        local OLDIFS="$IFS" IFS=':'
+        dotnetRuntimeDepsFlags+=("--suffix" "LD_LIBRARY_PATH" ":" "${libraryPathArray[*]}")
+        IFS="$OLDIFS"
+    fi
+
+    local dotnetRootFlagsArray=()
+    if [[ -z ${dotnetSelfContainedBuild-} ]]; then
+        if [[ -n ${useDotnetFromEnv-} ]]; then
             # if dotnet CLI is available, set DOTNET_ROOT based on it. Otherwise set to default .NET runtime
-            dotnetRootFlags+=("--run" 'command -v dotnet &>/dev/null && export DOTNET_ROOT="$(@dirname@ "$(@realpath@ "$(@which@ dotnet)")")" || export DOTNET_ROOT="@dotnetRuntime@"')
-            dotnetRootFlags+=("--suffix" "PATH" ":" "@dotnetRuntime@/bin")
+            dotnetRootFlagsArray+=("--suffix" "PATH" ":" "$wrapperPath")
+            dotnetRootFlagsArray+=("--run" "$dotnetFromEnvScript")
+            dotnetRootFlagsArray+=("--set-default" "DOTNET_ROOT" "$dotnetRuntime")
+            dotnetRootFlagsArray+=("--suffix" "PATH" ":" "$dotnetRuntime/bin")
         else
-            dotnetRootFlags+=("--set" "DOTNET_ROOT" "@dotnetRuntime@")
-            dotnetRootFlags+=("--prefix" "PATH" ":" "@dotnetRuntime@/bin")
+            dotnetRootFlagsArray+=("--set" "DOTNET_ROOT" "$dotnetRuntime")
+            dotnetRootFlagsArray+=("--prefix" "PATH" ":" "$dotnetRuntime/bin")
         fi
     fi
 
     makeWrapper "$1" "$2" \
-        --suffix "LD_LIBRARY_PATH" : "@runtimeDeps@" \
-        "${dotnetRootFlags[@]}" \
+        "${dotnetRuntimeDepsFlags[@]}" \
+        "${dotnetRootFlagsArray[@]}" \
         "${gappsWrapperArgs[@]}" \
-        "${makeWrapperArgs[@]}"
+        "${makeWrapperArgsArray[@]}"
 
     echo "installed wrapper to "$2""
 }
@@ -30,13 +57,24 @@ wrapDotnetProgram() {
 dotnetFixupHook() {
     echo "Executing dotnetFixupPhase"
 
-    # check if executables is declared (including empty values, in which case we generate no executables)
-    if declare -p executables &>/dev/null; then
-        for executable in ${executables[@]}; do
-            path="${installPath-$out/lib/$pname}/$executable"
+    local -r dotnetInstallPath="${dotnetInstallPath-$out/lib/$pname}"
+
+    local executable executableBasename
+
+    # check if dotnetExecutables is declared (including empty values, in which case we generate no executables)
+    if declare -p dotnetExecutables &>/dev/null; then
+        if [[ -n $__structuredAttrs ]]; then
+            local dotnetExecutablesArray=( "${dotnetExecutables[@]}" )
+        else
+            local dotnetExecutablesArray=($dotnetExecutables)
+        fi
+        for executable in "${dotnetExecutablesArray[@]}"; do
+            executableBasename=$(basename "$executable")
+
+            local path="$dotnetInstallPath/$executable"
 
             if test -x "$path"; then
-                wrapDotnetProgram "$path" "$out/bin/$(basename "$executable")"
+                wrapDotnetProgram "$path" "$out/bin/$executableBasename"
             else
                 echo "Specified binary \"$executable\" is either not an executable or does not exist!"
                 echo "Looked in $path"
@@ -45,8 +83,9 @@ dotnetFixupHook() {
         done
     else
         while IFS= read -d '' executable; do
-            wrapDotnetProgram "$executable" "$out/bin/$(basename "$executable")" \;
-        done < <(find "${installPath-$out/lib/$pname}" ! -name "*.dll" -executable -type f -print0)
+            executableBasename=$(basename "$executable")
+            wrapDotnetProgram "$executable" "$out/bin/$executableBasename" \;
+        done < <(find "$dotnetInstallPath" ! -name "*.dll" -executable -type f -print0)
     fi
 
     echo "Finished dotnetFixupPhase"
diff --git a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-install-hook.sh b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-install-hook.sh
index ed754d8ffcad9..4d9b3c502c354 100644
--- a/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-install-hook.sh
+++ b/pkgs/build-support/dotnet/build-dotnet-module/hooks/dotnet-install-hook.sh
@@ -1,70 +1,86 @@
-# inherit arguments from derivation
-dotnetInstallFlags=( ${dotnetInstallFlags[@]-} )
-
 dotnetInstallHook() {
     echo "Executing dotnetInstallHook"
 
     runHook preInstall
 
-    if [ "${selfContainedBuild-}" ]; then
-        dotnetInstallFlags+=("--self-contained")
+    local -r hostRuntimeId=@runtimeId@
+    local -r dotnetInstallPath="${dotnetInstallPath-$out/lib/$pname}"
+    local -r dotnetBuildType="${dotnetBuildType-Release}"
+    local -r dotnetRuntimeId="${dotnetRuntimeId-$hostRuntimeId}"
+
+    if [[ -n $__structuredAttrs ]]; then
+        local dotnetProjectFilesArray=( "${dotnetProjectFiles[@]}" )
+        local dotnetFlagsArray=( "${dotnetFlags[@]}" )
+        local dotnetInstallFlagsArray=( "${dotnetInstallFlags[@]}" )
+        local dotnetPackFlagsArray=( "${dotnetPackFlags[@]}" )
+    else
+        local dotnetProjectFilesArray=($dotnetProjectFiles)
+        local dotnetFlagsArray=($dotnetFlags)
+        local dotnetInstallFlagsArray=($dotnetInstallFlags)
+        local dotnetPackFlagsArray=($dotnetPackFlags)
+    fi
+
+    if [[ -n ${dotnetSelfContainedBuild-} ]]; then
+        dotnetInstallFlagsArray+=("--self-contained")
     else
-        dotnetInstallFlags+=("--no-self-contained")
+        dotnetInstallFlagsArray+=("--no-self-contained")
         # https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained
         # Trimming is only available for self-contained build, so force disable it here
-        dotnetInstallFlags+=("-p:PublishTrimmed=false")
+        dotnetInstallFlagsArray+=("-p:PublishTrimmed=false")
     fi
 
-    if [ "${useAppHost-}" ]; then
-        dotnetInstallFlags+=("-p:UseAppHost=true")
+    if [[ -n ${dotnetUseAppHost-} ]]; then
+        dotnetInstallFlagsArray+=("-p:UseAppHost=true")
     fi
 
     dotnetPublish() {
-        local -r project="${1-}"
+        local -r projectFile="${1-}"
 
-        runtimeIdFlags=()
-        if [[ "$project" == *.csproj ]] || [ "${selfContainedBuild-}" ]; then
-            runtimeIdFlags+=("--runtime @runtimeId@")
+        runtimeIdFlagsArray=()
+        if [[ $projectFile == *.csproj || -n ${dotnetSelfContainedBuild-} ]]; then
+            runtimeIdFlagsArray+=("--runtime" "$dotnetRuntimeId")
         fi
 
-        dotnet publish ${project-} \
+        dotnet publish ${1+"$projectFile"} \
             -p:ContinuousIntegrationBuild=true \
             -p:Deterministic=true \
-            --output "${installPath-$out/lib/$pname}" \
-            --configuration "@buildType@" \
+            --output "$dotnetInstallPath" \
+            --configuration "$dotnetBuildType" \
             --no-build \
-            ${runtimeIdFlags[@]} \
-            ${dotnetInstallFlags[@]}  \
-            ${dotnetFlags[@]}
+            "${runtimeIdFlagsArray[@]}" \
+            "${dotnetInstallFlagsArray[@]}" \
+            "${dotnetFlagsArray[@]}"
     }
 
     dotnetPack() {
-        local -r project="${1-}"
-        dotnet pack ${project-} \
+        local -r projectFile="${1-}"
+        dotnet pack ${1+"$projectFile"} \
             -p:ContinuousIntegrationBuild=true \
             -p:Deterministic=true \
             --output "$out/share" \
-            --configuration "@buildType@" \
+            --configuration "$dotnetBuildType" \
             --no-build \
-            --runtime "@runtimeId@" \
-            ${dotnetPackFlags[@]}  \
-            ${dotnetFlags[@]}
+            --runtime "$dotnetRuntimeId" \
+            "${dotnetPackFlagsArray[@]}" \
+            "${dotnetFlagsArray[@]}"
     }
 
-    if (( "${#projectFile[@]}" == 0 )); then
+    if (( ${#dotnetProjectFilesArray[@]} == 0 )); then
         dotnetPublish
     else
-        for project in ${projectFile[@]}; do
-            dotnetPublish "$project"
+        local projectFile
+        for projectFile in "${dotnetProjectFilesArray[@]}"; do
+            dotnetPublish "$projectFile"
         done
     fi
 
-    if [[ "${packNupkg-}" ]]; then
-        if (( "${#projectFile[@]}" == 0 )); then
+    if [[ -n ${packNupkg-} ]]; then
+        if (( ${#dotnetProjectFilesArray[@]} == 0 )); then
             dotnetPack
         else
-            for project in ${projectFile[@]}; do
-                dotnetPack "$project"
+            local projectFile
+            for projectFile in "${dotnetProjectFilesArray[@]}"; do
+                dotnetPack "$projectFile"
             done
         fi
     fi
diff --git a/pkgs/test/dotnet/default.nix b/pkgs/test/dotnet/default.nix
index 7592b09d76e3c..d70850c05fdb0 100644
--- a/pkgs/test/dotnet/default.nix
+++ b/pkgs/test/dotnet/default.nix
@@ -1,5 +1,7 @@
-{ callPackage }:
+{ lib, callPackage }:
 
 {
   project-references = callPackage ./project-references { };
+  use-dotnet-from-env = lib.recurseIntoAttrs (callPackage ./use-dotnet-from-env { });
+  structured-attrs = lib.recurseIntoAttrs (callPackage ./structured-attrs { });
 }
diff --git a/pkgs/test/dotnet/project-references/default.nix b/pkgs/test/dotnet/project-references/default.nix
index f40b9196c2091..4ac5cf2ad82e6 100644
--- a/pkgs/test/dotnet/project-references/default.nix
+++ b/pkgs/test/dotnet/project-references/default.nix
@@ -4,11 +4,13 @@
 
 { lib
 , dotnet-sdk
-, buildDotnetModule
+, buildPackages # buildDotnetModule
 , runCommand
 }:
 
 let
+  inherit (buildPackages) buildDotnetModule;
+
   nugetDeps = ./nuget-deps.nix;
 
   # Specify the TargetFramework via an environment variable so that we don't
@@ -18,7 +20,8 @@ let
   library = buildDotnetModule {
     name = "project-references-test-library";
     src = ./library;
-    inherit nugetDeps TargetFramework;
+    inherit nugetDeps;
+    env.TargetFramework = TargetFramework;
 
     packNupkg = true;
   };
@@ -26,7 +29,8 @@ let
   application = buildDotnetModule {
     name = "project-references-test-application";
     src = ./application;
-    inherit nugetDeps TargetFramework;
+    inherit nugetDeps;
+    env.TargetFramework = TargetFramework;
 
     projectReferences = [ library ];
   };
diff --git a/pkgs/test/dotnet/structured-attrs/default.nix b/pkgs/test/dotnet/structured-attrs/default.nix
new file mode 100644
index 0000000000000..cf96fef8dbdcc
--- /dev/null
+++ b/pkgs/test/dotnet/structured-attrs/default.nix
@@ -0,0 +1,36 @@
+{ lib
+, dotnet-sdk
+, buildPackages # buildDotnetModule
+, testers
+, runCommand
+}:
+let
+  # Note: without structured attributes, we can’t use derivation arguments that
+  # contain spaces unambiguously because arguments are passed as space-separated
+  # environment variables.
+  copyrightString = "Public domain 🅮";
+
+  inherit (buildPackages) buildDotnetModule;
+
+  app = buildDotnetModule {
+    name = "structured-attrs-test-application";
+    src = ./src;
+    nugetDeps = ./nuget-deps.nix;
+    dotnetFlags = [ "--property:Copyright=${copyrightString}" ];
+    env.TargetFramework = "net${lib.versions.majorMinor (lib.getVersion dotnet-sdk)}";
+    __structuredAttrs = true;
+  };
+in
+{
+  no-structured-attrs = testers.testBuildFailure (app.overrideAttrs {
+    __structuredAttrs = false;
+  });
+
+  check-output = testers.testEqualContents {
+    assertion = "buildDotnetModule sets AssemblyCopyrightAttribute with structured attributes";
+    expected = builtins.toFile "expected-copyright.txt" copyrightString;
+    actual = runCommand "dotnet-structured-attrs-test" { } ''
+      ${app}/bin/Application >"$out"
+    '';
+  };
+}
diff --git a/pkgs/test/dotnet/structured-attrs/nuget-deps.nix b/pkgs/test/dotnet/structured-attrs/nuget-deps.nix
new file mode 100644
index 0000000000000..f3a17967e25c8
--- /dev/null
+++ b/pkgs/test/dotnet/structured-attrs/nuget-deps.nix
@@ -0,0 +1,5 @@
+# This file was automatically generated by passthru.fetch-deps.
+# Please dont edit it manually, your changes might get overwritten!
+
+{ fetchNuGet }: [
+]
diff --git a/pkgs/test/dotnet/structured-attrs/src/Application.cs b/pkgs/test/dotnet/structured-attrs/src/Application.cs
new file mode 100644
index 0000000000000..3bc548105c2b4
--- /dev/null
+++ b/pkgs/test/dotnet/structured-attrs/src/Application.cs
@@ -0,0 +1,10 @@
+using System;
+using System.Reflection;
+
+Console.Write(
+    (
+        (AssemblyCopyrightAttribute)Assembly
+            .GetExecutingAssembly()
+            .GetCustomAttributes(typeof(AssemblyCopyrightAttribute), true)[0]
+    ).Copyright
+);
diff --git a/pkgs/test/dotnet/structured-attrs/src/Application.csproj b/pkgs/test/dotnet/structured-attrs/src/Application.csproj
new file mode 100644
index 0000000000000..decaa6d961aab
--- /dev/null
+++ b/pkgs/test/dotnet/structured-attrs/src/Application.csproj
@@ -0,0 +1,5 @@
+<Project Sdk="Microsoft.NET.Sdk">
+    <PropertyGroup>
+        <OutputType>exe</OutputType>
+    </PropertyGroup>
+</Project>
diff --git a/pkgs/test/dotnet/use-dotnet-from-env/default.nix b/pkgs/test/dotnet/use-dotnet-from-env/default.nix
new file mode 100644
index 0000000000000..711a98eb0c29d
--- /dev/null
+++ b/pkgs/test/dotnet/use-dotnet-from-env/default.nix
@@ -0,0 +1,60 @@
+{ lib
+, dotnet-sdk
+, buildPackages # buildDotnetModule, dotnet-runtime
+, testers
+, runCommand
+, removeReferencesTo
+}:
+let
+  inherit (buildPackages) buildDotnetModule dotnet-runtime;
+
+  app = buildDotnetModule {
+    name = "use-dotnet-from-env-test-application";
+    src = ./src;
+    nugetDeps = ./nuget-deps.nix;
+    useDotnetFromEnv = true;
+    env.TargetFramework = "net${lib.versions.majorMinor (lib.getVersion dotnet-sdk)}";
+  };
+
+  appWithoutFallback = app.overrideAttrs (oldAttrs: {
+    nativeBuildInputs = (oldAttrs.nativeBuildInputs or [ ]) ++ [
+      removeReferencesTo
+    ];
+    postFixup = (oldAttrs.postFixup or "") + ''
+      remove-references-to -t ${dotnet-runtime} "$out/bin/Application"
+    '';
+  });
+
+  runtimeVersion = lib.getVersion dotnet-runtime;
+  runtimeVersionFile = builtins.toFile "dotnet-version.txt" runtimeVersion;
+in
+{
+  fallback = testers.testEqualContents {
+    assertion = "buildDotnetModule sets fallback DOTNET_ROOT in wrapper";
+    expected = runtimeVersionFile;
+    actual = runCommand "use-dotnet-from-env-fallback-test" { } ''
+      ${app}/bin/Application >"$out"
+    '';
+  };
+
+  # Check that appWithoutFallback does not use fallback .NET runtime.
+  without-fallback = testers.testBuildFailure (runCommand "use-dotnet-from-env-without-fallback-test" { } ''
+    ${appWithoutFallback}/bin/Application >"$out"
+  '');
+
+  # NB assumes that without-fallback above to passes.
+  use-dotnet-root-env = testers.testEqualContents {
+    assertion = "buildDotnetModule uses DOTNET_ROOT from environment in wrapper";
+    expected = runtimeVersionFile;
+    actual = runCommand "use-dotnet-from-env-root-test" { env.DOTNET_ROOT = dotnet-runtime; } ''
+      ${appWithoutFallback}/bin/Application >"$out"
+    '';
+  };
+  use-dotnet-path-env = testers.testEqualContents {
+    assertion = "buildDotnetModule uses DOTNET_ROOT from dotnet in PATH in wrapper";
+    expected = runtimeVersionFile;
+    actual = runCommand "use-dotnet-from-env-path-test" { dotnetRuntime = dotnet-runtime; } ''
+      PATH=$dotnetRuntime''${PATH+:}$PATH ${appWithoutFallback}/bin/Application >"$out"
+    '';
+  };
+}
diff --git a/pkgs/test/dotnet/use-dotnet-from-env/nuget-deps.nix b/pkgs/test/dotnet/use-dotnet-from-env/nuget-deps.nix
new file mode 100644
index 0000000000000..f3a17967e25c8
--- /dev/null
+++ b/pkgs/test/dotnet/use-dotnet-from-env/nuget-deps.nix
@@ -0,0 +1,5 @@
+# This file was automatically generated by passthru.fetch-deps.
+# Please dont edit it manually, your changes might get overwritten!
+
+{ fetchNuGet }: [
+]
diff --git a/pkgs/test/dotnet/use-dotnet-from-env/src/Application.cs b/pkgs/test/dotnet/use-dotnet-from-env/src/Application.cs
new file mode 100644
index 0000000000000..5efc571ca9a3e
--- /dev/null
+++ b/pkgs/test/dotnet/use-dotnet-from-env/src/Application.cs
@@ -0,0 +1,3 @@
+using System;
+
+Console.Write(Environment.Version.ToString());
diff --git a/pkgs/test/dotnet/use-dotnet-from-env/src/Application.csproj b/pkgs/test/dotnet/use-dotnet-from-env/src/Application.csproj
new file mode 100644
index 0000000000000..decaa6d961aab
--- /dev/null
+++ b/pkgs/test/dotnet/use-dotnet-from-env/src/Application.csproj
@@ -0,0 +1,5 @@
+<Project Sdk="Microsoft.NET.Sdk">
+    <PropertyGroup>
+        <OutputType>exe</OutputType>
+    </PropertyGroup>
+</Project>