Skip to content
Draft
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
2 changes: 1 addition & 1 deletion build/_build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<!-- Test properties for MSBuild integration -->
<PropertyGroup>
<FalloutTasksEnabled Condition="'$(FalloutTasksEnabled)' == ''">False</FalloutTasksEnabled>
<FalloutTasksDirectory>$(MSBuildThisFileDirectory)\..\src\Fallout.MSBuildTasks\bin\Debug\net8.0\publish</FalloutTasksDirectory>
<FalloutTasksDirectory>$(MSBuildThisFileDirectory)\..\src\Fallout.MSBuildTasks\bin\Debug\net10.0\publish</FalloutTasksDirectory>

<!-- <PackAsTool>True</PackAsTool>-->
<!-- <ToolCommandName>build</ToolCommandName>-->
Expand Down
62 changes: 62 additions & 0 deletions docs/adr/0009-lift-ns20-floor-via-msbuild-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ADR-0009: Lift the `netstandard2.0` floor off the tooling stack via an out-of-process MSBuild bridge

## Status

Proposed (2026-06-30). Implements the **Lever 2** half of [#441](https://github.com/Fallout-build/Fallout/issues/441) ("move off the ns2.0/net472 floor"); the **Lever 1** half (freeing the `Fallout.SourceGenerators` Roslyn cone) is tracked separately in [#442](https://github.com/Fallout-build/Fallout/pull/442) and is an independent cone — the two do not interact. Unblocks the deferred onion lifts (`AbsolutePath`, `Host`, and lifting `IFalloutBuild` into `Fallout.Core`).

## Context

Two project graphs are pinned to `netstandard2.0`, for unrelated reasons:

- **Cone A — `Fallout.SourceGenerators`** is a Roslyn source generator and is ns2.0 by compiler requirement. Its references (`Build.Shared`, `Solution`, `Persistence.Solution`) do **not** touch the tooling stack. Addressed by Lever 1 (#442); out of scope here.
- **Cone B — `Fallout.MSBuildTasks`** targets `net10.0;net472`. Its `net472` flavour exists because the MSBuild tasks it ships are loaded **into the consumer's MSBuild host**, and the in-IDE build of VS 2019/2022 (and `msbuild.exe`) is **full-framework** MSBuild, which can only load a `net472` task assembly in-process. That `net472` reference is the **sole** force dragging `Fallout.Tooling` and `Fallout.Tooling.Generator` down to a `net10.0;netstandard2.0` multi-target:

```
Fallout.MSBuildTasks (net10.0;net472) ──► Fallout.Tooling (net10.0;netstandard2.0)
└─► Fallout.Tooling.Generator (netstandard2.0)
```

Because `Fallout.Tooling` is ns2.0 and `Fallout.Core` is ns2.1, anything `Tooling` must consume cannot live in Core. That is the direct cause of:

- the tool-execution contract living in the dedicated ns2.0 leaves `Fallout.Application.Tooling.Execution` / `.Requirements` instead of Core;
- `Configure<T>` being un-liftable to Core (Phase 2a deferral);
- `AbsolutePath` (2b) and `Host` (2c) being un-liftable, which in turn blocks lifting `IFalloutBuild` into Core (2e).

The three concrete tasks (`CodeGenerationTask`, `EmbedPackagesForSelfContainedTask`, `PackPackageToolsTask`, all extending `ContextAwareTask : Microsoft.Build.Utilities.Task`) are build-time **file producers** — tool-wrapper codegen, package embedding, tool packing. None needs a chatty in-process MSBuild API; each is expressible as *inputs → work → outputs + diagnostics*. That makes them safe to run out-of-process.

`ContextAwareTask` carries a `CustomAssemblyLoader : AssemblyLoadContext` whose only job is to isolate Fallout's dependency closure from MSBuild's own assemblies inside the host — a known-fragile piece that exists purely because the work runs in-process.

## Decision

Extract the task **logic** into a `net10` engine and front it with two thin adapters, selected by MSBuild runtime:

```
Fallout.MSBuildTasks.Engine (net10) ← the real work; references net10 Tooling + Generator
├─ Fallout.MSBuildTasks (net10) in-proc Task → calls the engine directly
└─ Fallout.MSBuildTasks.Bridge (net472) ToolTask → shells out to a standalone net10 worker
```

1. **`Fallout.Tooling` and `Fallout.Tooling.Generator` become `net10`-only.** With the `net472` consumer gone, the floor is lifted. `Fallout.Core` (or a single set of net-modern contracts) can then absorb the tool contracts, `Configure<T>`, and `AbsolutePath` — unblocking 2b/2c/2e.
2. **Core MSBuild hosts (`dotnet` / SDK build) load the in-proc `net10` task** — the hot path, no process spawn. It keeps the `ContextAwareTask`/`AssemblyLoadContext` isolation (loading net10 Tooling + Serilog/STJ into SDK-MSBuild can still clash).
3. **Full-framework MSBuild hosts (VS 2019/2022, `msbuild.exe`) load the `net472` bridge** — a `Microsoft.Build.Utilities.ToolTask` that shells out via `System.Diagnostics.Process` to a **standalone net10 worker console** (`Fallout.MSBuildTasks.Worker`). `ToolTask` is purpose-built for this: process launch, output capture, cancellation, and `LogEventsFromTextOutput` to surface worker diagnostics in the VS error list.
- The bridge references **only** `Microsoft.Build.*` + the BCL — **zero Fallout dependencies**, no `AssemblyLoadContext` dance.
- The worker is **standalone**, not a verb on the `fallout` CLI — this is a time-boxed workaround and must add no surface to the long-lived CLI.
4. **`.targets` selects the adapter on `$(MSBuildRuntimeType)`** (`Core` → in-proc task; `Full` → bridge). The legacy `Nuke*`→`Fallout*` property bridging and `FalloutTasksEnabled` flag are untouched.
5. **`dotnet` host discovery** uses `$(DOTNET_HOST_PATH)` (set by the SDK), falling back to PATH, passed to the bridge so it can launch `dotnet Fallout.MSBuildTasks.Worker.dll`.
6. **I/O marshalling stays boring** — `ITaskItem[]`/properties (e.g. `FalloutSpecificationFiles`) serialize to a temp JSON arg file; the worker writes its output files plus a small results JSON the bridge reads back for output item groups.

The net10 runtime being present at the consumer's build is **assumed, not mitigated** — Fallout is a net10 build system; if net10 were absent, nothing would run.

## Consequences

- **Unblocks the de-static "lift to Core" track** (2b `AbsolutePath`, 2c `Host`, 2e `IFalloutBuild`) and lets the ns2.0 tooling leaves (`Application.Tooling.Execution`/`.Requirements`) eventually fold back into Core.
- **Deletes the fragile in-host ALC isolation for VS** — the bridge has no Fallout deps to isolate.
- **Adds a per-invocation process spawn for full-framework builds only.** These are build-time, run-once-per-build operations; the cost is negligible and the hot `dotnet`/CLI path keeps its in-proc speed.
- **The bridge is a fenced, deletable legacy adapter** for VS 2019/2022. VS 2026's move to a 64-bit/.NET MSBuild is expected to load net10 tasks in-process directly, at which point `Full` hosts also get a modern runtime and the bridge is dead weight. **Retirement = delete the `.Bridge` project + the `Full` branch in `.targets`.** Keep it past then only if it is costing nothing.
- **Behaviour parity is the bar:** VS-driven consumer builds (codegen, package embed, tool pack) must produce identical output and identical error-list diagnostics through the bridge as through the in-proc task.

## Alternatives considered

- **Drop `net472` outright** (the original Lever 2 framing). Simplest, but breaks the Fallout MSBuild tasks for in-VS / `msbuild.exe` builds on VS 2019/2022. Rejected: the bridge preserves those at low, time-boxed cost.
- **Unify everyone onto the out-of-process worker** (no in-proc task). One code path, but pays the process-spawn cost on every `dotnet`/CLI build and discards the existing ALC isolation that already works. Rejected in favour of in-proc-for-Core, bridge-for-Full.
- **MSBuild task host (`Runtime="NET"`)** to run a net-core task from full-framework MSBuild. Not a reliable cross-runtime path from net472; the explicit shell-out is more robust and fully decoupled.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ If you change a decision, do NOT silently rewrite the old ADR — add a new one
| [0004](0004-calendar-versioning-and-dual-pace-channels.md) | Calendar versioning + dual-pace channels (edge/stable) + experimental APIs | Accepted (§3 amended by 0007; channel ladder §2 superseded by 0008) |
| [0007](0007-cut-release-branch-on-demand.md) | Cut `release/YYYY` on demand, not preemptively | Accepted |
| [0008](0008-collapse-experimental-into-main.md) | Collapse `experimental` into `main`; `main` is the sole prerelease lane | Accepted |
| [0009](0009-lift-ns20-floor-via-msbuild-bridge.md) | Lift the `netstandard2.0` floor off the tooling stack via an out-of-process MSBuild bridge | Proposed |
120 changes: 120 additions & 0 deletions docs/agents/msbuild-bridge-smoke-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# MSBuild bridge smoke test (PR #447 / ADR-0009)

Runbook for verifying the **full-framework MSBuild bridge** added by [ADR-0009](../adr/0009-lift-ns20-floor-via-msbuild-bridge.md)
(branch `tooling/lift-ns20-floor`). CI does **not** cover this path — the repo dogfood runs
`FalloutTasksEnabled=False`, and the bridge only runs under full-framework `MSBuild.exe`, which is
**Windows-only**. So it needs a manual, Windows + VS smoke test.

## What exercises which path

The `.targets` select by `$(MSBuildRuntimeType)`:

| Driver | RuntimeType | Path | Covered by |
|---|---|---|---|
| `dotnet` / SDK MSBuild | `Core` | in-proc net10 task (`build/netcore`) | `dotnet pack`, CI |
| VS 2019 / 2022 `MSBuild.exe` | `Full` | **net472 bridge → net10 worker** (`build/netfx`) | **this runbook only** |
| VS 2026 (v18) `MSBuild.exe` | `Core` | in-proc net10 task | same as `dotnet` |

**VS 2026 does not exercise the bridge** — its MSBuild is .NET-based, so it takes the in-proc path.
The bridge is reachable *only* via full-framework MSBuild (VS 2019/2022 or Build Tools). Per the ADR
the bridge is **time-boxed**: delete it once consumers are off full-framework MSBuild.

## VS version currency (as of 2026-07)

| Version | Status | Bridge? |
|---|---|---|
| VS 2026 (v18) | current | no (Core) |
| VS 2022 (v17) | prior gen, widely used | **yes** |
| VS 2019 (v16) | legacy, near EOL; its MSBuild can't resolve the net10 SDK | **yes** |
| VS for Mac | **retired 2024-08-31**; never had full-framework MSBuild | n/a |

## Homelab VM spec

A parked Windows image, spun up only when touching the bridge / MSBuild-task code:

- Windows 10/11 or Windows Server.
- **VS Build Tools 2022** with the `Microsoft.Component.MSBuild` workload — the BuildTools SKU is
enough; full VS is not needed. (Optionally Build Tools 2019 too, for the oldest supported path.)
- The pinned **.NET SDK** (`global.json`, currently `10.0.100`) — provides `dotnet` for the worker.
- `git`.

Does **not** need VS 2026 — that only re-tests the `Core` path the dev box's `dotnet` already covers.

## How to run

From a worktree on the PR branch (`MSBuild.exe` runs must use PowerShell, not Git Bash — Bash
mangles `/switch` args and `/c/...` paths):

```powershell
# 1. Publish the three task projects (Release)
dotnet publish src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.csproj -c Release
dotnet publish src/Fallout.MSBuildTasks.Bridge/Fallout.MSBuildTasks.Bridge.csproj -c Release
dotnet publish src/Fallout.MSBuildTasks.Worker/Fallout.MSBuildTasks.Worker.csproj -c Release
dotnet build src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj -c Release

# 2. Pack Fallout.Common (gate on _IsPacking) and extract its build/ assets
dotnet pack src/Fallout.Common/Fallout.Common.csproj -c Release -p:_IsPacking=true -o <feed>
# unzip <feed>\Fallout.Common.*.nupkg -> <pkg>, giving <pkg>\build\{netcore,netfx}

# 3. Consumer that imports <pkg>\build\Fallout.Common.{props,targets}, sets a tool spec in
# @(FalloutSpecificationFiles), and runs the FalloutCodeGeneration target. A non-SDK .proj
# lets BOTH VS2019 and VS2022 evaluate it (VS2019's MSBuild can't resolve the net10 SDK).

$env:DOTNET_HOST_PATH = "C:\Program Files\dotnet\dotnet.exe" # so the bridge can launch the worker
& "<VS>\MSBuild\Current\Bin\MSBuild.exe" Consumer.proj -t:FalloutCodeGeneration -p:PkgBuild=<pkg>\build
```

**Pass criteria:** the `Full`-path runs produce the *same* generated output as `dotnet` (`Core`),
with the worker process actually spawned and no load/protocol errors.

## Result — 2026-07-01 (commit 7220ab5a)

| Driver | Outcome |
|---|---|
| `dotnet` (Core) | ✅ generated valid `Git.Generated.cs` (in-proc net10 task) |
| VS 2022 `MSBuild.exe` (Full) | ❌ bridge crashes before spawning the worker |
| VS 2019 `MSBuild.exe` (Full) | ❌ identical crash |

**Defect — the net472 bridge can't load `System.Text.Json`.**

```
error MSB6003: System.IO.FileNotFoundException: Could not load file or assembly
'System.Text.Json, Version=10.0.0.0, ... PublicKeyToken=cc7b13ffcd2ddd51'
at Fallout.MSBuildTasks.Protocol.WorkerJson.Serialize[T]
at CodeGenerationTask.BuildRequestJson() -> WorkerBridgeTask.GenerateCommandLineCommands()
```

Root cause:
- `Fallout.MSBuildTasks.Protocol` (`WorkerProtocol.cs`) serializes with **`System.Text.Json`**.
- The packaged STJ is assembly version **`10.0.0.8`**; the code references **`10.0.0.0`**.
- The bridge runs **in-process inside .NET-Framework `MSBuild.exe`**, where fusion needs an exact
version or a binding redirect. There is none (and a `Bridge.dll.config` redirect would not help —
fusion only reads the host `MSBuild.exe.config` for in-proc assemblies). On .NET Core both the
in-proc task and the worker bind fine via roll-forward, which is why only `Full` fails.
- This contradicts ADR-0009 §3 ("the bridge references **only** `Microsoft.Build.*` + the BCL —
zero Fallout dependencies"). The Protocol's STJ dependency reintroduces exactly the version-
sensitive in-proc dependency the design set out to avoid.

**Fix direction:** make `WorkerJson` framework-safe so the net472 bridge carries no redirect-
sensitive dependency — e.g. serialize the (trivial) DTOs with the in-box `DataContractJsonSerializer`,
or hand-roll, keeping STJ (if at all) on the net10 worker side only. The blocker is the bridge's
*outbound* serialization (`BuildRequestJson`); the worker (net10) is fine.

### Fixed — 2026-07-01

`WorkerJson` (`Fallout.MSBuildTasks.Protocol`) now uses the in-box `DataContractJsonSerializer`
(`System.Runtime.Serialization`) instead of `System.Text.Json`, and the STJ `PackageReference` is
removed from the Protocol project. STJ no longer ships in `build/netfx`, so the net472 bridge loads
with no redirect-sensitive dependency. Re-run:

| Driver | Outcome |
|---|---|
| `dotnet` (Core) | ✅ `Git.Generated.cs` |
| VS 2022 `MSBuild.exe` (Full) | ✅ bridge → worker, output **byte-identical** to Core |
| VS 2019 `MSBuild.exe` (Full) | ✅ identical |

All three generated files share one SHA256 — behaviour parity (ADR-0009 §"Consequences") holds.

> No automated tests cover the bridge/worker serialization — this smoke test is the only coverage.
> A `WorkerJson` round-trip unit test in a `Fallout.MSBuildTasks.Protocol.Specs` sibling would catch
> a regression without the manual VS run.
5 changes: 5 additions & 0 deletions fallout.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
<Project Path="src\Shims\Nuke.Common\Nuke.Common.csproj" />
<Project Path="src\Shims\Nuke.Components\Nuke.Components.csproj" />
<Project Path="src\Fallout.MSBuildTasks\Fallout.MSBuildTasks.csproj" />
<Project Path="src\Fallout.MSBuildTasks.Engine\Fallout.MSBuildTasks.Engine.csproj" />
<Project Path="src\Fallout.MSBuildTasks.Protocol\Fallout.MSBuildTasks.Protocol.csproj" />
<Project Path="src\Fallout.MSBuildTasks.Worker\Fallout.MSBuildTasks.Worker.csproj" />
<Project Path="src\Fallout.MSBuildTasks.Bridge\Fallout.MSBuildTasks.Bridge.csproj" />
<Project Path="src\Fallout.ProjectModel\Fallout.ProjectModel.csproj" />
<Project Path="src\Persistence\Fallout.Persistence.Solution\Fallout.Persistence.Solution.csproj" />
<Project Path="src\Persistence\Fallout.Solution\Fallout.Solution.csproj" />
Expand All @@ -42,6 +46,7 @@
<Project Path="tests\Fallout.Cli.Specs\Fallout.Cli.Specs.csproj" />
<Project Path="tests\Fallout.Migrate.Specs\Fallout.Migrate.Specs.csproj" />
<Project Path="tests\Fallout.Migrate.Analyzers.Specs\Fallout.Migrate.Analyzers.Specs.csproj" />
<Project Path="tests\Fallout.MSBuildTasks.Protocol.Specs\Fallout.MSBuildTasks.Protocol.Specs.csproj" />
<Project Path="tests\Nuke.Common.Shim.Specs\Nuke.Common.Shim.Specs.csproj" />
<Project Path="tests\Nuke.Components.Shim.Specs\Nuke.Components.Shim.Specs.csproj" />
<Project Path="tests\Fallout.ProjectModel.Specs\Fallout.ProjectModel.Specs.csproj" />
Expand Down
9 changes: 8 additions & 1 deletion src/Fallout.Common/Fallout.Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,15 @@
<None Include="$(MSBuildProjectName).targets" PackagePath="build" Pack="true" />
<None Include="..\Fallout.MSBuildTasks\Fallout.MSBuildTasks.targets" PackagePath="build\netcore" Pack="true" />
<None Include="..\Fallout.MSBuildTasks\Fallout.MSBuildTasks.targets" PackagePath="build\netfx" Pack="true" />
<!-- Core MSBuild (dotnet/SDK): the in-process net10 task + its dependency closure. -->
<None Include="..\Fallout.MSBuildTasks\bin\$(Configuration)\net10.0\publish\**\*.*" PackagePath="build\netcore" Pack="true" />
<None Include="..\Fallout.MSBuildTasks\bin\$(Configuration)\net472\publish\**\*.*" PackagePath="build\netfx" Pack="true" />
<!--
Full-framework MSBuild (VS2019/2022): the net472 bridge plus the net10 worker it shells out
to (sitting in the same folder so the bridge resolves Fallout.MSBuildTasks.Worker.dll beside
itself). See docs/adr/0009-lift-ns20-floor-via-msbuild-bridge.md.
-->
<None Include="..\Fallout.MSBuildTasks.Bridge\bin\$(Configuration)\net472\publish\**\*.*" PackagePath="build\netfx" Pack="true" />
<None Include="..\Fallout.MSBuildTasks.Worker\bin\$(Configuration)\net10.0\publish\**\*.*" PackagePath="build\netfx" Pack="true" />
<None Include="..\Fallout.SourceGenerators\bin\$(Configuration)\netstandard2.0\*.dll" PackagePath="analyzers\dotnet\cs" Pack="true" />
</ItemGroup>

Expand Down
33 changes: 33 additions & 0 deletions src/Fallout.MSBuildTasks.Bridge/CodeGenerationTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Linq;
using Fallout.MSBuildTasks.Protocol;
using Microsoft.Build.Framework;

namespace Fallout.MSBuildTasks;

/// <summary>Full-framework shim for the tool-wrapper code generation task.</summary>
public sealed class CodeGenerationTask : WorkerBridgeTask
{
[Required]
public ITaskItem[] SpecificationFiles { get; set; }

[Required]
public string BaseDirectory { get; set; }

public bool UseNestedNamespaces { get; set; }

public string BaseNamespace { get; set; }

public bool UpdateReferences { get; set; }

protected override string Verb => WorkerVerbs.Codegen;

protected override string BuildRequestJson()
=> WorkerJson.Serialize(new CodegenRequest
{
SpecificationFiles = SpecificationFiles.Select(x => x.GetMetadata("FullPath")).ToArray(),
BaseDirectory = BaseDirectory,
UseNestedNamespaces = UseNestedNamespaces,
BaseNamespace = BaseNamespace,
UpdateReferences = UpdateReferences,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Linq;
using Fallout.MSBuildTasks.Protocol;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Fallout.MSBuildTasks;

/// <summary>Full-framework shim for resolving packages to embed for self-contained single-file builds.</summary>
public sealed class EmbedPackagesForSelfContainedTask : WorkerBridgeTask
{
[Required]
public string ProjectAssetsFile { get; set; }

[Required]
public string TargetFramework { get; set; }

[Output]
public ITaskItem[] TargetOutputs { get; set; }

protected override string Verb => WorkerVerbs.EmbedPackages;

protected override string BuildRequestJson()
=> WorkerJson.Serialize(new EmbedPackagesRequest { ProjectAssetsFile = ProjectAssetsFile });

protected override void ConsumeResponse(WorkerResponse response)
=> TargetOutputs = response.Files.Select(x => (ITaskItem)new TaskItem(x)).ToArray();
}
Loading
Loading