Skip to content

Static web assets discovery only sees wwwroot content present at project evaluation time, not files written by a BeforeBuild/Build-hooked target #55883

Description

@Popl7

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area-AspNetCoreRazorSDK, BlazorWebAssemblySDK, StaticWebAssetsSDKuntriagedRequest triage from a team member

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions