Summary
A custom MSBuild target hooked at BeforeTargets="BeforeBuild" (or "Build") that writes a generated file straight into wwwroot — the common pattern taught by several real "add Tailwind/Sass/esbuild to ASP.NET Core" tutorials — can silently fail to get a route in the published static web assets endpoints manifest ({Project}.staticwebassets.endpoints.json), even though the file itself is written to disk correctly at the right path and size. Under MapStaticAssets() (added in .NET 9), that means the request 404s in production despite the asset existing.
Originally filed against dotnet/aspnetcore — full investigation, multiple real repro repos, and CI runs are there: dotnet/aspnetcore#68641. Filing here too since tracing it into the actual source landed in this repo, not aspnetcore.
Root cause (as far as traced)
ResolveProjectStaticWebAssets is the target responsible for turning wwwroot content into StaticWebAsset/manifest entries:
|
<Target Name="ResolveProjectStaticWebAssets" |
|
Condition="'$(NoBuild)' != 'true'" |
|
BeforeTargets="AssignTargetPaths" |
|
DependsOnTargets="ResolveStaticWebAssetsConfiguration;UpdateExistingPackageStaticWebAssets;AssignLinkMetadata"> |
|
|
|
<PropertyGroup> |
|
<_ResolveProjectStaticWebAssetsCachePath Condition="'$(StaticWebAssetsCacheDefineStaticWebAssetsEnabled)' == 'true'">$(_StaticWebAssetsManifestBase)rpswa.dswa.cache.json</_ResolveProjectStaticWebAssetsCachePath> |
|
</PropertyGroup> |
|
|
|
<DefineStaticWebAssets |
|
CandidateAssets="@(Content->Distinct())" |
|
FingerprintCandidates="$(StaticWebAssetsFingerprintContent)" |
|
FingerprintPatterns="@(StaticWebAssetFingerprintPattern)" |
|
RelativePathPattern="wwwroot/**" |
|
SourceType="Discovered" |
|
SourceId="$(PackageId)" |
|
ContentRoot="$(MSBuildProjectDirectory)\wwwroot\" |
|
BasePath="$(StaticWebAssetBasePath)" |
|
AssetMergeSource="$(StaticWebAssetMergeTarget)" |
|
CacheManifestPath="$(_ResolveProjectStaticWebAssetsCachePath)" |
|
StaticWebAssetGroupDefinitions="@(StaticWebAssetGroupDefinition)"> |
|
<Output TaskParameter="Assets" ItemName="StaticWebAsset" /> |
|
<Output TaskParameter="Assets" ItemName="_CurrentProjectStaticWebAsset" /> |
|
</DefineStaticWebAssets> |
|
|
|
<DefineStaticWebAssetEndpoints |
|
CandidateAssets="@(_CurrentProjectStaticWebAsset)" |
|
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)" |
|
AdditionalEndpointDefinitions="@(StaticWebAssetAdditionalEndpointDefinition)" |
|
> |
|
<Output TaskParameter="Endpoints" ItemName="StaticWebAssetEndpoint" /> |
|
</DefineStaticWebAssetEndpoints> |
|
|
|
<ItemGroup> |
|
|
|
<StaticWebAssetDiscoveryPattern Include="$(PackageId)\wwwroot" Condition="Exists('$(MSBuildProjectDirectory)\wwwroot')"> |
|
<Source>$(PackageId)</Source> |
|
<BasePath>$(StaticWebAssetBasePath)</BasePath> |
|
<ContentRoot>$(MSBuildProjectDirectory)\wwwroot\</ContentRoot> |
|
<Pattern>**</Pattern> |
|
</StaticWebAssetDiscoveryPattern> |
|
|
|
<Content Remove="@(StaticWebAsset)" /> |
|
|
|
<FileWrites Include="$(_ResolveProjectStaticWebAssetsCachePath)" /> |
<Target Name="ResolveProjectStaticWebAssets"
Condition="'$(NoBuild)' != 'true'"
BeforeTargets="AssignTargetPaths"
DependsOnTargets="ResolveStaticWebAssetsConfiguration;UpdateExistingPackageStaticWebAssets;AssignLinkMetadata">
...
<DefineStaticWebAssets
CandidateAssets="@(Content->Distinct())"
...
RelativePathPattern="wwwroot/**"
SourceType="Discovered"
...
CandidateAssets="@(Content->Distinct())" — this target doesn't scan the filesystem itself. It filters whatever's already in the Content item collection at the point it runs, down to wwwroot/**. That collection is populated by the SDK's default-item globs at project evaluation time, once, before any target executes. A plain file write from a BeforeBuild/Build-hooked target's Exec (or any task) never touches the Content item collection — it just puts a file on disk. ResolveProjectStaticWebAssets never re-scans disk to notice it.
Two things get a generated file into the manifest correctly:
- It was already on disk when the project was evaluated. Confirmed this exactly: two real example repos (dune-ui/blog-posts, khalidabuhakmeh/TailwindAspNetCore) happen to commit their generated CSS to git rather than gitignoring it, and that alone is what avoids the bug — removing that one committed file and rebuilding from a genuinely fresh checkout reproduces it immediately, 3/3 local + confirmed on GitHub Actions.
- An explicit
<ItemGroup><Content Include=".." Link="wwwroot/.." /></ItemGroup> inside the generating target — the pattern from Microsoft's own Razor Class Library client assets guidance. This is the fix that's held up in every test.
Minimal repro
https://github.com/Popl7/essessay-tailwind-manifest-repro — several branches isolating variables one at a time:
main — original Tailwind CLI, BeforeTargets="BeforeBuild"
no-external-process-repro — same bug with a trivial in-process WriteLinesToFile, no Tailwind, no external process at all
tutorial-pattern-repro — the literal shape real tutorials teach (BeforeTargets="Build")
dotnet8-repro / dotnet9-repro / dotnet11-repro — same underlying discovery gap exists on .NET 8 too (confirmed via obj/**/staticwebassets.build.json), but is only user-visible from .NET 9 onward since UseStaticFiles() (pre-MapStaticAssets()) never consults that manifest for a plain app's own assets
official-pattern-repro — the Content/Link fix, confirmed working
Each has a GitHub Actions workflow (workflow_dispatch) that builds with --no-cache and checks both the manifest and the actual HTTP response.
What's still unexplained
The committed-vs-gitignored mechanism above is fully confirmed. What I haven't traced into the source: from-scratch minimal repros with no pre-existing file on either side reproduce 100% reliably on GitHub Actions/Render, but never reproduce on local Docker Desktop (macOS, arm64 and amd64-emulated, under various CPU/memory constraints) — same commit, same Dockerfile, same single dotnet publish invocation. Given Content is fixed at evaluation time either way, I'd naively expect this to be fully deterministic (always missing) once there's genuinely no pre-existing file — but it isn't, it's environment-dependent. Something after this point (possibly in Publish's own separate resolution pass, or an evaluation-cache detail) must be doing something I haven't found. Flagging in case it's a useful thread for someone closer to this code.
Happy to provide binlogs from a passing and a failing build if that would help narrow it down.
Summary
A custom MSBuild target hooked at
BeforeTargets="BeforeBuild"(or"Build") that writes a generated file straight intowwwroot— the common pattern taught by several real "add Tailwind/Sass/esbuild to ASP.NET Core" tutorials — can silently fail to get a route in the published static web assets endpoints manifest ({Project}.staticwebassets.endpoints.json), even though the file itself is written to disk correctly at the right path and size. UnderMapStaticAssets()(added in .NET 9), that means the request 404s in production despite the asset existing.Originally filed against
dotnet/aspnetcore— full investigation, multiple real repro repos, and CI runs are there: dotnet/aspnetcore#68641. Filing here too since tracing it into the actual source landed in this repo, not aspnetcore.Root cause (as far as traced)
ResolveProjectStaticWebAssetsis the target responsible for turningwwwrootcontent intoStaticWebAsset/manifest entries:sdk/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets
Lines 768 to 812 in 7053edd
CandidateAssets="@(Content->Distinct())"— this target doesn't scan the filesystem itself. It filters whatever's already in theContentitem collection at the point it runs, down towwwroot/**. That collection is populated by the SDK's default-item globs at project evaluation time, once, before any target executes. A plain file write from aBeforeBuild/Build-hooked target'sExec(or any task) never touches theContentitem collection — it just puts a file on disk.ResolveProjectStaticWebAssetsnever re-scans disk to notice it.Two things get a generated file into the manifest correctly:
<ItemGroup><Content Include=".." Link="wwwroot/.." /></ItemGroup>inside the generating target — the pattern from Microsoft's own Razor Class Library client assets guidance. This is the fix that's held up in every test.Minimal repro
https://github.com/Popl7/essessay-tailwind-manifest-repro — several branches isolating variables one at a time:
main— original Tailwind CLI,BeforeTargets="BeforeBuild"no-external-process-repro— same bug with a trivial in-processWriteLinesToFile, no Tailwind, no external process at alltutorial-pattern-repro— the literal shape real tutorials teach (BeforeTargets="Build")dotnet8-repro/dotnet9-repro/dotnet11-repro— same underlying discovery gap exists on .NET 8 too (confirmed viaobj/**/staticwebassets.build.json), but is only user-visible from .NET 9 onward sinceUseStaticFiles()(pre-MapStaticAssets()) never consults that manifest for a plain app's own assetsofficial-pattern-repro— theContent/Linkfix, confirmed workingEach has a GitHub Actions workflow (
workflow_dispatch) that builds with--no-cacheand checks both the manifest and the actual HTTP response.What's still unexplained
The committed-vs-gitignored mechanism above is fully confirmed. What I haven't traced into the source: from-scratch minimal repros with no pre-existing file on either side reproduce 100% reliably on GitHub Actions/Render, but never reproduce on local Docker Desktop (macOS, arm64 and amd64-emulated, under various CPU/memory constraints) — same commit, same Dockerfile, same single
dotnet publishinvocation. GivenContentis fixed at evaluation time either way, I'd naively expect this to be fully deterministic (always missing) once there's genuinely no pre-existing file — but it isn't, it's environment-dependent. Something after this point (possibly in Publish's own separate resolution pass, or an evaluation-cache detail) must be doing something I haven't found. Flagging in case it's a useful thread for someone closer to this code.Happy to provide binlogs from a passing and a failing build if that would help narrow it down.