Summary
When a repo sets UseArtifactsOutput=true, a class library multi-targets net10.0;net10.0-android36.1, and an Android application (AndroidApplication=true) references it, dotnet build on the solution fails with a chain of three errors. Each one can be worked around independently, exposing the next link in the chain. The third (XALNS7028) comes from LinkAssembliesNoShrink; the first two are from the .NET SDK / Microsoft.Common.targets, but all three cascade from the same Android-SDK-driven inner per-RID library build (_ComputeFilesToPublishForRuntimeIdentifiers).
I could not reproduce without UseArtifactsOutput=true. With the legacy bin//obj/ layout the chain does not fire.
Environment
- .NET SDK
11.0.100-preview.4.26230.115
- Workloads:
android 36.99.0-preview.4.137/11.0.100-preview.4, maui-windows, ios, maccatalyst
Microsoft.Android.Sdk.Windows 36.1.53
- Windows 11
UseArtifactsOutput=true set at repo root in Directory.Build.props
Minimal repro (10 files, inline)
Build with dotnet build Repro.slnx -c Debug from the repro root.
Directory.Build.props
<Project>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
Directory.Build.targets
Empty by default. The three workarounds (uncomment one at a time to walk the chain):
<Project>
<!-- WORKAROUND 1 — fixes NETSDK1047 -->
<Target Name="_SetRIDsForRestoreProjectSpec"
BeforeTargets="_GenerateRestoreProjectSpec"
Condition="$(TargetFrameworks.Contains('-android'))">
<PropertyGroup>
<RuntimeIdentifiers Condition="'$(RuntimeIdentifiers)' == ''">android-arm64;android-x64</RuntimeIdentifiers>
</PropertyGroup>
</Target>
<!-- WORKAROUND 2 — fixes MSB3030 -->
<Target Name="_SeedRidIntermediateAssemblyForAndroidLibraries"
BeforeTargets="CopyFilesToOutputDirectory"
Condition="'$(SkipCompilerExecution)' == 'true'
AND '$(AppendRuntimeIdentifierToOutputPath)' == 'true'
AND '$(AndroidApplication)' != 'true'
AND '$(RuntimeIdentifier)' != ''">
<PropertyGroup>
<_RidAssemblyPath>@(IntermediateAssembly)</_RidAssemblyPath>
<_RidRefAssemblyPath>@(IntermediateRefAssembly)</_RidRefAssemblyPath>
<_NonRidAssemblyPath>$([System.String]::new('$(_RidAssemblyPath)').Replace('_$(RuntimeIdentifier)\', '\').Replace('_$(RuntimeIdentifier)/', '/'))</_NonRidAssemblyPath>
<_NonRidRefAssemblyPath>$([System.String]::new('$(_RidRefAssemblyPath)').Replace('_$(RuntimeIdentifier)\', '\').Replace('_$(RuntimeIdentifier)/', '/'))</_NonRidRefAssemblyPath>
</PropertyGroup>
<MakeDir Directories="$(IntermediateOutputPath)" />
<MakeDir Directories="$(IntermediateOutputPath)refint" Condition="'$(_RidRefAssemblyPath)' != ''" />
<Copy SourceFiles="$(_NonRidAssemblyPath)"
DestinationFolder="$(IntermediateOutputPath)"
Condition="Exists('$(_NonRidAssemblyPath)')" />
<Copy SourceFiles="$(_NonRidRefAssemblyPath)"
DestinationFolder="$(IntermediateOutputPath)refint"
Condition="'$(_RidRefAssemblyPath)' != '' AND Exists('$(_NonRidRefAssemblyPath)')" />
</Target>
<!-- WORKAROUND 3 — fixes XALNS7028 -->
<Target Name="_FixLibraryTargetPathForInnerRidBuild"
BeforeTargets="GetTargetPathWithTargetPlatformMoniker"
Condition="'$(_OuterOutputPath)' != ''
AND '$(AndroidApplication)' != 'true'
AND '$(RuntimeIdentifier)' != ''">
<PropertyGroup>
<TargetPath>$(OutDir)$(TargetFileName)</TargetPath>
</PropertyGroup>
</Target>
</Project>
Repro.slnx
<Solution>
<Project Path="MyLib/MyLib.csproj" />
<Project Path="MyApp/MyApp.csproj" />
</Solution>
MyLib/MyLib.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android36.1</TargetFrameworks>
</PropertyGroup>
</Project>
MyLib/Greeter.cs
namespace MyLib;
public class Greeter
{
public string Hello() =>
#if ANDROID
"Hello from Android";
#else
"Hello from Desktop";
#endif
}
MyApp/MyApp.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android36.1</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<OutputType>Exe</OutputType>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-android36.1'">
<AndroidApplication>true</AndroidApplication>
<RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>
<ApplicationId>com.example.xalns7028</ApplicationId>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyLib\MyLib.csproj" SetTargetFramework="TargetFramework=$(TargetFramework)" />
</ItemGroup>
</Project>
MyApp/Program.cs
#if !ANDROID
System.Console.WriteLine(new MyLib.Greeter().Hello());
#endif
MyApp/MainActivity.cs
#if ANDROID
using Android.App;
using Android.OS;
namespace MyApp;
[Activity(Label = "Repro", MainLauncher = true)]
public class MainActivity : Activity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
System.Console.WriteLine(new MyLib.Greeter().Hello());
}
}
#endif
Error chain
1. NETSDK1047 (no workarounds)
error NETSDK1047: Assets file '.../artifacts/obj/MyLib/project.assets.json'
doesn't have a target for 'net10.0-android36.1/android-arm64'. Ensure that
restore has run and that you have included 'net10.0-android36.1' in the
TargetFrameworks for your project. You may also need to include 'android-arm64'
in your project's RuntimeIdentifiers.
[.../MyLib/MyLib.csproj::TargetFramework=net10.0-android36.1]
MyLib is a library — RuntimeIdentifiers is not set in its csproj (libraries don't normally need it). When the Android SDK's inner per-RID build of MyLib runs (kicked off by MyApp's apk pipeline via _ComputeFilesToPublishForRuntimeIdentifiers), ResolvePackageAssets requires net10.0-android36.1/android-arm64 in project.assets.json, but NuGet restore only produced net10.0-android36.1 (no RID) for the library.
Workaround 1 (_SetRIDsForRestoreProjectSpec) forces RuntimeIdentifiers=android-arm64;android-x64 on the library during restore-spec generation, so the assets file gets the per-RID targets the SDK will later ask for.
2. MSB3030 (after workaround 1)
error MSB3030: Could not copy the file
".../artifacts/obj/MyLib/release_net10.0-android36.1_android-x64/MyLib.dll"
because it was not found.
[.../MyLib/MyLib.csproj::TargetFramework=net10.0-android36.1]
The Android SDK invokes the inner per-RID library build with:
AppendRuntimeIdentifierToOutputPath=true — shifts OutputPath/OutDir to a RID-suffixed path
SkipCompilerExecution=true — Roslyn exits immediately; no dll is produced
_OuterOutputPath=<MyApp outer bin> — AssemblyResolution.targets PropertyGroup overrides OutDir at evaluation time
AppendRuntimeIdentifierToOutputPath=true also shifts @(IntermediateAssembly) to the RID-suffixed obj path. SkipCompilerExecution=true means no dll is written there. CopyFilesToOutputDirectory then tries to copy @(IntermediateAssembly) from that path → MSB3030.
Workaround 2 (_SeedRidIntermediateAssemblyForAndroidLibraries) copies the already-built non-RID assembly (produced by the outer multi-target build of MyLib) into the RID-suffixed obj dir so CopyFilesToOutputDirectory has a source.
3. XALNS7028 (after workarounds 1 + 2)
error XALNS7028: System.IO.FileNotFoundException: Could not load assembly
'MyLib, Version=0.0.0.0, Culture=neutral, PublicKeyToken='.
Perhaps it doesn't exist in the Mono for Android profile?
File name: 'MyLib.dll'
at Java.Interop.Tools.Cecil.DirectoryAssemblyResolver.Resolve(AssemblyNameReference reference, ReaderParameters parameters)
at Xamarin.Android.Tasks.AssemblyModifierPipeline.RunPipeline(AssemblyPipeline pipeline, ITaskItem source, ITaskItem destination)
at Xamarin.Android.Tasks.AssemblyModifierPipeline.RunTask()
at Microsoft.Android.Build.Tasks.AndroidTask.Execute()
[.../MyApp/MyApp.csproj::TargetFramework=net10.0-android36.1]
After workaround 2 the dll exists in obj, gets copied to bin, and CopyFilesToOutputDirectory succeeds. But the resolver in LinkAssembliesNoShrink still can't find MyLib.dll — even though it is right there in MyApp's outer bin.
Root cause: TargetPath is a static property set at project evaluation time from OutDir + TargetFileName. The SDK's DefaultOutputPaths.targets sets it first using the RID-suffixed ArtifactsPivots. The Android SDK's AssemblyResolution.targets then overrides OutDir to $(_OuterOutputPath) via a top-level PropertyGroup — but the TargetPath PropertyGroup is conditioned on '$(TargetPath)' == '' and no longer fires. GetTargetPath returns the stale RID-suffixed library bin path (which never has a dll), and that path ends up in @(ResolvedAssemblies) as a search directory. The dll isn't there, and Cecil throws FileNotFoundException.
Workaround 3 (_FixLibraryTargetPathForInnerRidBuild) runs BeforeTargets="GetTargetPathWithTargetPlatformMoniker" and forcibly sets TargetPath from the corrected OutDir. @(ResolvedAssemblies) then points at the right directory and the resolver succeeds.
BeforeTargets="GetTargetPathWithTargetPlatformMoniker" rather than GetTargetPath because in CLI builds ResolveProjectReferences calls GetTargetPathWithTargetPlatformMoniker directly, and in VS builds GetTargetPath triggers it as a BeforeTargets — in both cases we must update TargetPath before it's captured into @(TargetPathWithTargetPlatformMoniker).
Asymmetry with VS
dotnet build solution.slnx from the CLI triggers the apk pipeline for Android apps and fires the full chain. dotnet build of an Android app project alone (or of a single TFM) often doesn't reach LinkAssembliesNoShrink because the apk packaging targets are skipped — so the same project that fails in VS Rebuild All may appear to build clean on a focused CLI build. This makes the bug easy to miss until someone opens the solution in VS.
Proposed fixes (three independent root causes)
-
NuGet restore for multi-target Android libraries. When a library project's TargetFrameworks includes an Android moniker, _GenerateRestoreProjectSpec could default RuntimeIdentifiers to the platform RIDs (android-arm64;android-x64, plus android-arm / android-x86 for older targets) so the assets file gets the per-RID targets the SDK will later ask for. The library author shouldn't need to know that consumers will trigger per-RID builds.
-
SkipCompilerExecution=true + AppendRuntimeIdentifierToOutputPath=true. CopyFilesToOutputDirectory should either skip when there is no compile output, or the Android SDK should not shift IntermediateAssembly to a RID-suffixed obj path that nothing writes to.
-
_OuterOutputPath override leaks stale TargetPath. When the Android SDK overrides OutDir after project evaluation, TargetPath should be recomputed — either by re-running the DefaultOutputPaths.targets PropertyGroup, or by changing GetTargetPath to derive from OutDir at execution time instead of using the cached TargetPath property. Otherwise stale paths leak into @(ResolvedAssemblies) and the assembly resolver fails for assemblies that are physically present at the new location.
(Filed from the field — happy to test patches against this repro.)
Summary
When a repo sets
UseArtifactsOutput=true, a class library multi-targetsnet10.0;net10.0-android36.1, and an Android application (AndroidApplication=true) references it,dotnet buildon the solution fails with a chain of three errors. Each one can be worked around independently, exposing the next link in the chain. The third (XALNS7028) comes fromLinkAssembliesNoShrink; the first two are from the .NET SDK / Microsoft.Common.targets, but all three cascade from the same Android-SDK-driven inner per-RID library build (_ComputeFilesToPublishForRuntimeIdentifiers).I could not reproduce without
UseArtifactsOutput=true. With the legacybin//obj/layout the chain does not fire.Environment
11.0.100-preview.4.26230.115android 36.99.0-preview.4.137/11.0.100-preview.4,maui-windows,ios,maccatalystMicrosoft.Android.Sdk.Windows36.1.53UseArtifactsOutput=trueset at repo root inDirectory.Build.propsMinimal repro (10 files, inline)
Build with
dotnet build Repro.slnx -c Debugfrom the repro root.Directory.Build.propsDirectory.Build.targetsEmpty by default. The three workarounds (uncomment one at a time to walk the chain):
Repro.slnxMyLib/MyLib.csprojMyLib/Greeter.csMyApp/MyApp.csprojMyApp/Program.csMyApp/MainActivity.csError chain
1.
NETSDK1047(no workarounds)MyLibis a library —RuntimeIdentifiersis not set in its csproj (libraries don't normally need it). When the Android SDK's inner per-RID build ofMyLibruns (kicked off byMyApp's apk pipeline via_ComputeFilesToPublishForRuntimeIdentifiers),ResolvePackageAssetsrequiresnet10.0-android36.1/android-arm64inproject.assets.json, but NuGet restore only producednet10.0-android36.1(no RID) for the library.Workaround 1 (
_SetRIDsForRestoreProjectSpec) forcesRuntimeIdentifiers=android-arm64;android-x64on the library during restore-spec generation, so the assets file gets the per-RID targets the SDK will later ask for.2.
MSB3030(after workaround 1)The Android SDK invokes the inner per-RID library build with:
AppendRuntimeIdentifierToOutputPath=true— shiftsOutputPath/OutDirto a RID-suffixed pathSkipCompilerExecution=true— Roslyn exits immediately; no dll is produced_OuterOutputPath=<MyApp outer bin>—AssemblyResolution.targetsPropertyGroup overridesOutDirat evaluation timeAppendRuntimeIdentifierToOutputPath=truealso shifts@(IntermediateAssembly)to the RID-suffixedobjpath.SkipCompilerExecution=truemeans no dll is written there.CopyFilesToOutputDirectorythen tries to copy@(IntermediateAssembly)from that path → MSB3030.Workaround 2 (
_SeedRidIntermediateAssemblyForAndroidLibraries) copies the already-built non-RID assembly (produced by the outer multi-target build ofMyLib) into the RID-suffixedobjdir soCopyFilesToOutputDirectoryhas a source.3.
XALNS7028(after workarounds 1 + 2)After workaround 2 the dll exists in
obj, gets copied tobin, andCopyFilesToOutputDirectorysucceeds. But the resolver inLinkAssembliesNoShrinkstill can't findMyLib.dll— even though it is right there inMyApp's outer bin.Root cause:
TargetPathis a static property set at project evaluation time fromOutDir + TargetFileName. The SDK'sDefaultOutputPaths.targetssets it first using the RID-suffixedArtifactsPivots. The Android SDK'sAssemblyResolution.targetsthen overridesOutDirto$(_OuterOutputPath)via a top-levelPropertyGroup— but theTargetPathPropertyGroup is conditioned on'$(TargetPath)' == ''and no longer fires.GetTargetPathreturns the stale RID-suffixed library bin path (which never has a dll), and that path ends up in@(ResolvedAssemblies)as a search directory. The dll isn't there, and Cecil throwsFileNotFoundException.Workaround 3 (
_FixLibraryTargetPathForInnerRidBuild) runsBeforeTargets="GetTargetPathWithTargetPlatformMoniker"and forcibly setsTargetPathfrom the correctedOutDir.@(ResolvedAssemblies)then points at the right directory and the resolver succeeds.BeforeTargets="GetTargetPathWithTargetPlatformMoniker"rather thanGetTargetPathbecause in CLI buildsResolveProjectReferencescallsGetTargetPathWithTargetPlatformMonikerdirectly, and in VS buildsGetTargetPathtriggers it as aBeforeTargets— in both cases we must updateTargetPathbefore it's captured into@(TargetPathWithTargetPlatformMoniker).Asymmetry with VS
dotnet build solution.slnxfrom the CLI triggers the apk pipeline for Android apps and fires the full chain.dotnet buildof an Android app project alone (or of a single TFM) often doesn't reachLinkAssembliesNoShrinkbecause the apk packaging targets are skipped — so the same project that fails in VS Rebuild All may appear to build clean on a focused CLI build. This makes the bug easy to miss until someone opens the solution in VS.Proposed fixes (three independent root causes)
NuGet restore for multi-target Android libraries. When a library project's
TargetFrameworksincludes an Android moniker,_GenerateRestoreProjectSpeccould defaultRuntimeIdentifiersto the platform RIDs (android-arm64;android-x64, plusandroid-arm/android-x86for older targets) so the assets file gets the per-RID targets the SDK will later ask for. The library author shouldn't need to know that consumers will trigger per-RID builds.SkipCompilerExecution=true+AppendRuntimeIdentifierToOutputPath=true.CopyFilesToOutputDirectoryshould either skip when there is no compile output, or the Android SDK should not shiftIntermediateAssemblyto a RID-suffixedobjpath that nothing writes to._OuterOutputPathoverride leaks staleTargetPath. When the Android SDK overridesOutDirafter project evaluation,TargetPathshould be recomputed — either by re-running theDefaultOutputPaths.targetsPropertyGroup, or by changingGetTargetPathto derive fromOutDirat execution time instead of using the cachedTargetPathproperty. Otherwise stale paths leak into@(ResolvedAssemblies)and the assembly resolver fails for assemblies that are physically present at the new location.(Filed from the field — happy to test patches against this repro.)