Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/main-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1055,12 +1055,55 @@ jobs:
# csproj (project-local — a global -p:RuntimeIdentifiers breaks referenced libs, see above).
# `latest` (the tag the plugins repo pins as MW_TEST_IMAGE) and the GHCR mirror both moved
# to `promote`: they are consumer-visible, so they must not appear until every leg is green.
# 🚨 THE RAZOR COMPILER, STAGED FOR BOTH IMAGE ARCHITECTURES.
# `mw-plugin-test build-project` compiles .razor/.cshtml, and Razor compilation in the .NET
# SDK is a Roslyn SOURCE GENERATOR. This image ships the runtime and no SDK, so the generator
# has to travel with the builder — and it CANNOT be copied once: the SDK crossgens it for the
# SDK's own RID, so the same 10.0.400 file carries PE machine 0xFD1D on linux-x64 and 0xD11D
# on linux-arm64, and the wrong one throws BadImageFormatException at run time and nowhere
# else. This image publishes BOTH architectures from ONE x64 host, so both copies are staged
# and the builder picks the one for the RID it is running on.
#
# The arm64 copy comes from the dotnet/sdk image of the EXACT SAME SDK VERSION the runner is
# using — one source of truth, no second pin to drift — and `docker create` + `docker cp`
# reads it out without ever executing an arm64 binary, so no qemu is needed.
- name: Stage the Razor source generator for linux-x64 and linux-arm64
id: razor
run: |
set -euo pipefail
test "$(uname -m)" = "x86_64" \
|| { echo "::error::this step assumes an x64 runner (it takes the host SDK's copy as the linux-x64 one)"; exit 1; }
SDK_VERSION="$(dotnet --version)"
SDK_ROOT="$(dotnet --list-sdks | sed -n "s|^${SDK_VERSION} \[\(.*\)\]$|\1|p" | head -1)"
HOST_SOURCE="$SDK_ROOT/$SDK_VERSION/Sdks/Microsoft.NET.Sdk.Razor/source-generators"
ROOT="$RUNNER_TEMP/razor-generators"
echo "SDK $SDK_VERSION; host source-generators: $HOST_SOURCE"

mkdir -p "$ROOT/linux-x64" "$ROOT/linux-arm64"
cp "$HOST_SOURCE"/*.dll "$ROOT/linux-x64/"

IMAGE="mcr.microsoft.com/dotnet/sdk:$SDK_VERSION"
docker pull --platform linux/arm64 "$IMAGE"
CID="$(docker create --platform linux/arm64 "$IMAGE")"
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
docker cp "$CID:/usr/share/dotnet/sdk/$SDK_VERSION/Sdks/Microsoft.NET.Sdk.Razor/source-generators/." \
"$ROOT/linux-arm64/"

# 🚨 Fail closed. An empty or half-staged RID ships an image whose Razor builds fail on
# exactly one architecture — which nothing in this pipeline would notice.
for rid in linux-x64 linux-arm64; do
test -f "$ROOT/$rid/Microsoft.CodeAnalysis.Razor.Compiler.dll" \
|| { echo "::error::no Razor compiler staged for $rid"; exit 1; }
echo "$rid: $(ls "$ROOT/$rid" | tr '\n' ' ')"
done
echo "root=$ROOT" >> "$GITHUB_OUTPUT"
- name: Build + push mw-plugin-test (staging tag, ACR)
run: >
dotnet publish tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj
-c Release --no-self-contained -t:PublishContainer -p:PublishProfile=
-p:ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"'
-p:CIRun=true
-p:MeshWeaverRazorGeneratorRoot=${{ steps.razor.outputs.root }}
-p:ContainerRegistry=${{ env.ACR }}
-p:ContainerRepository=mw-plugin-test
-p:ContainerImageTags=${{ needs.gate.outputs.staging_tag }}
Expand Down
9 changes: 8 additions & 1 deletion src/MeshWeaver.Cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,17 @@ assembly the image already carries. See
| `--output <dir>` | where the emitted assemblies land (default: a temp dir) |
| `--root <dir>` | directory mounted as `/repo` (default: the nearest `Directory.Build.props` ancestor) |
| `--extra-refs <dir>` | libraries ADDITIONAL to the platform — the only way to satisfy a `PackageReference` the image does not supply. Repeatable |
| `--accept <construct>` | acknowledge one construct the evaluator cannot reproduce (`target:<Name>`, `embedded-resource`, `conditions`). Repeatable |
| `--accept <construct>` | acknowledge one construct the evaluator cannot reproduce (`target:<Name>`, `embedded-resource`, `conditions`, `razor-css-scope`, `razor-not-compiled`). Repeatable |
| `--no-warn` / `--allow-warnings` | warnings fail the build (default); `--no-warn=false` or `--allow-warnings` opts out |
| `--no-pull` | use the image the docker daemon already has, for one built locally |

**Razor/Blazor compiles** (2026-08-31): the image ships the SDK's Razor source generator beside the
builder, per RID, and `build-project` runs it for any project whose `Sdk` processes Razor items —
`MeshWeaver.Blazor` (31 `.cs` + 42 `.razor`) builds green against `memex-portal-ai`. What it will
not do quietly is skip a `.razor` file: a project with Razor input and no generator fails by name,
and CSS isolation (`*.razor.css`) needs `--accept razor-css-scope` because the `b-…` scope comes
from an MSBuild task this builder does not run.

🚨 **Nothing is dropped in silence.** A project construct this builder cannot reproduce — an unknown
element, an unevaluatable `Condition`, a `<Target>`, an `<EmbeddedResource>` — FAILS the run naming
the construct and the file, and a `PackageReference` the container does not supply is reported by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,17 +392,92 @@ with `--accept targets --accept embedded-resource` and ClosedXML + CsvHelper as
| | count | why |
|---|---|---|
| **green** | **12** | incl. `MeshWeaver.Import` — 90 source files, 0 warnings under warnings-as-errors |
| Razor/Blazor (CS0115) | 15 | `.razor` components are not compiled — see below |
| Razor/Blazor (CS0115) | 15 | *fixed 2026-08-31 — see below* |
| source generators (CS8795) | 15 | `[GeneratedRegex]` / `[LoggerMessage]` / `[JsonSerializable]` |
| gRPC `<Protobuf>` | 3 | protoc codegen is a build task, not a compile |
| additional libraries | 5 | Snowflake.Data, Microsoft.Data.Sqlite, Azure.Cosmos, … — supply with `--extra-refs` |
| portal hosts | 3 | an `<Import>` above the mount; Aspire's `<Sdk>` ELEMENT |

## The container compiles Razor (2026-08-31)

Razor was the biggest single category above, and it was one missing file rather than a missing
feature: **Razor compilation in the .NET SDK is a Roslyn source generator**
(`Microsoft.CodeAnalysis.Razor.Compiler`) that turns each `.razor` into the partial class carrying
its generated `BuildRenderTree` override. A runtime image ships no SDK, so without the generator
every component compiled to a class with nothing to override — a wall of CS0115 that reads exactly
like broken source.

The image now carries it, in `razor-generators/` beside the builder, and `build-project` finds and
runs it automatically for any project whose `Sdk` processes Razor items.

### The dependency closure, measured

Exactly **two** assemblies, established by reading the compiler's own assembly references and then
proving it by deleting one:

| assembly | why |
|---|---|
| `Microsoft.CodeAnalysis.Razor.Compiler` | carries `RazorSourceGenerator`, the `[Generator]` type |
| `Microsoft.AspNetCore.Razor.Utilities.Shared` | its one private dependency — with it absent the compiler loads, its types enumerate, and every call into it throws |

Everything else it references — `netstandard`, `Microsoft.CodeAnalysis(.CSharp)`,
`System.Collections.Immutable`, `System.Memory`, `System.Buffers` — binds to the assemblies the
image already has. 🚨 **`Microsoft.Extensions.ObjectPool` is NOT needed**, though an older Razor
compiler build referenced it; the belief that it was is exactly why the closure was measured rather
than assumed.

### Two traps, both of which fail at run time and nowhere else

🚨 **The generator is built against the SDK's Roslyn, not the image's.** SDK 10.0.400's copy
references `Microsoft.CodeAnalysis` **5.9.0.0** while the image carries this repo's pin, **5.6.0**.
The default load context binds by name *and refuses a lower version*, so a plain `Assembly.LoadFrom`
fails — and a generator loader that treats "cannot load" as "not a generator" then produces a build
indistinguishable from one where nobody asked for Razor. The generator is therefore loaded into a
context that binds every assembly the HOST already has to the host's copy, version ignored. That is
also what keeps `ISourceGenerator` ONE type: a second Roslyn in that context would give the
generator a different interface than the driver expects.

🚨 **The generator is ReadyToRun-compiled for the SDK's own RID.** The same SDK 10.0.400 file carries
PE machine `0xFD1D` on linux-x64, `0xD11D` on linux-arm64 and `0xEC20` on osx-arm64 (the target
machine XOR'd with the operating system's R2R marker), and the wrong one throws
`BadImageFormatException`. `mw-plugin-test` publishes BOTH architectures from ONE x64 build host, so
copying the build machine's SDK once would have shipped an arm64 image that cannot compile a single
Blazor project. CD stages one directory per RID (`razor-generators/<rid>/`, the arm64 copy read out
of the dotnet/sdk image of the same SDK version with `docker create` + `docker cp`, so no emulation
is involved), and the builder picks the directory for the RID it is running on.

### What it refuses, and why

- **A project with Razor files and no generator.** One named failure that says what the CS0115 wall
would have been, not the wall.
- **A generator that ran and emitted nothing.** Not a compile error — a generator that did not
recognise its input, which otherwise fails four steps later as CS0115.
- **CSS isolation (`*.razor.css`).** The `b-…` scope identifier comes from the SDK's
`ComputeCssScope`/`ApplyCssScopes` tasks, and this builder runs no MSBuild task, so the components
would compile without their scope attributes. `--accept razor-css-scope` builds them anyway (the
assembly is valid; only the isolated stylesheet stops applying). Reproducing the scope hash from
memory is the guess this evaluator exists to avoid.
- **`.razor` under a project whose `Sdk` does not process Razor.** The SDK's build ignores them too,
so the outcome matches — but it is stated rather than skipped. `--accept razor-not-compiled`.

### Measured, 2026-08-31 — 10 of the 11 Razor projects

Every `Microsoft.NET.Sdk.Razor` project in `MeshWeaver.Plugins/src`, built against
`memex-portal-ai@sha256:6f38db08…` with the builder mounted, `--accept targets --accept
razor-css-scope --accept embedded-resource`:

| | count | |
|---|---|---|
| **green, no extra help** | **7** | Blazor (31 `.cs` + **42 `.razor`**), Blazor.EntityViews, Blazor.Graph, Blazor.Analysis, Blazor.OpenStreetMap, Blazor.GoogleMaps, Blazor.AppleMaps |
| green with `--generators` (the SDK's regex generator) | +2 | Blazor.Views, Blazor.Portal — they were blocked by `[GeneratedRegex]` in `MeshWeaver.Markdown.Collaboration`, not by Razor |
| green with `--extra-refs` | +1 | Blazor.Radzen — `Radzen.Blazor` is an additional library |
| still red | 1 | `Memex.Portal.Gui` — a transitive `MeshWeaver.Hosting.Grpc` carries `<Protobuf>`; protoc is a build task, not a compile |

**Razor is no longer what blocks any of them.** The two remaining categories are the pre-existing
ones — SDK source generators and `<Protobuf>`.

### What it does NOT do, and what each would take

- **Razor/Blazor.** `.razor` and `.cshtml` are not compiled at all. Doing so needs
`Microsoft.CodeAnalysis.Razor.Compiler`, `Microsoft.AspNetCore.Razor.Utilities.Shared` and
`Microsoft.Extensions.ObjectPool` present in the image — today it ships none of them.
- **Source generators.** They live in the .NET **SDK**, and a MeshWeaver image ships the **runtime**.
`--generators <dir|dll>` loads them when supplied; with none, the builder names the generator that
did not run rather than leaving a wall of CS8795 that reads like broken source.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
<ProjectReference Include="..\..\tools\MeshWeaver.ComboVerifier\MeshWeaver.ComboVerifier.csproj" />
</ItemGroup>

<ItemGroup>
<!-- RazorBuildTest LOADS the Blazor component it just compiled and reads the generated
BuildRenderTree override off it — which resolves ComponentBase, so this host has to be able
to bind Microsoft.AspNetCore.App. Without it the assertion cannot run at all, and "the
compiler returned" would be the only evidence a component exists, which is exactly the
CS0115 blind spot the suite is about. Same reason MeshWeaver.Auth.Test carries it. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<!-- The gate boots in-process here, and the gate requires the AI engine MODULE beside the
binary (#2276) — modules/ is laid by the closure lane, not copied by a ProjectReference, so
this TEST bin needs its own lane run with the same tester-local single row. Same pattern as
Expand Down
Loading
Loading