diff --git a/src/Fallout.Migrate/Common/Summary.cs b/src/Fallout.Migrate/Common/Summary.cs
index c554568d7..903dc7e8f 100644
--- a/src/Fallout.Migrate/Common/Summary.cs
+++ b/src/Fallout.Migrate/Common/Summary.cs
@@ -17,6 +17,12 @@ internal sealed class Summary
/// The number of directories renamed (currently just .nuke/ → .fallout/).
public int DirectoriesRenamed { get; set; }
+ ///
+ /// Set when the user declined the confirmation prompt in .
+ /// stops executing further steps once this is set.
+ ///
+ public bool Cancelled { get; set; }
+
/// Human-readable warnings about conditions the migration could not resolve automatically.
public List Warnings { get; } = new();
}
diff --git a/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs b/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs
new file mode 100644
index 000000000..39f703a93
--- /dev/null
+++ b/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs
@@ -0,0 +1,49 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Fallout.Migrate.Common;
+
+///
+/// Shared parsing helpers for modern, dotted .NET target framework monikers (e.g. net10.0),
+/// used by migration steps that compare a project's current TFM against a minimum.
+///
+internal static class TargetFrameworkMonikers
+{
+ ///
+ /// Matches a modern, dotted .NET moniker such as net8.0 or net10.0.
+ ///
+ private static readonly Regex modernMonikerPattern = new(
+ @"^net(?\d+)\.\d+$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ ///
+ /// Extracts the major version segment from a modern TFM moniker such as net10.0.
+ ///
+ /// A modern, dotted target framework moniker.
+ /// The parsed major version.
+ public static int ExtractMajor(string moniker)
+ {
+ return int.Parse(
+ modernMonikerPattern.Match(moniker).Groups["major"].Value,
+ CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// Returns true when targets an older .NET than
+ /// — including non-modern monikers (.NET Framework, .NET
+ /// Standard, out-of-support netcoreapp*) which never satisfy any minimum.
+ ///
+ /// A single target framework moniker, e.g. net8.0.
+ /// The minimum supported major version.
+ public static bool IsOlderThanMinimumSupported(string moniker, int minimumMajor)
+ {
+ Match match = modernMonikerPattern.Match(moniker);
+ if (!match.Success)
+ {
+ return true;
+ }
+
+ int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture);
+ return major < minimumMajor;
+ }
+}
diff --git a/src/Fallout.Migrate/Fallout.Migrate.csproj b/src/Fallout.Migrate/Fallout.Migrate.csproj
index 6067a5548..666352d73 100644
--- a/src/Fallout.Migrate/Fallout.Migrate.csproj
+++ b/src/Fallout.Migrate/Fallout.Migrate.csproj
@@ -8,6 +8,7 @@
fallout-migrate
CLI that migrates an existing NUKE consumer repo to Fallout in one command.
build automation continuous-integration tools orchestration migration
+ 10.0.0
diff --git a/src/Fallout.Migrate/MigrateCommand.cs b/src/Fallout.Migrate/MigrateCommand.cs
index 1d161f5e4..2078cde3a 100644
--- a/src/Fallout.Migrate/MigrateCommand.cs
+++ b/src/Fallout.Migrate/MigrateCommand.cs
@@ -52,6 +52,11 @@ public override async Task ExecuteAsync(CommandContext context, MigrateSett
var migration = new Migration(rootDirectory, settings.DryRun, Console.Out);
Summary summary = await migration.RunAsync();
+ if (summary.Cancelled)
+ {
+ return 1;
+ }
+
PrintSummary(summary, settings.DryRun);
return 0;
}
diff --git a/src/Fallout.Migrate/Migration.cs b/src/Fallout.Migrate/Migration.cs
index d54d854e8..aa8d6e0ed 100644
--- a/src/Fallout.Migrate/Migration.cs
+++ b/src/Fallout.Migrate/Migration.cs
@@ -24,14 +24,18 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri
private static readonly IReadOnlyList steps =
[
new ResolveFalloutVersionStep(),
+ new VerifyBuildTargetFrameworkStep(),
+ new ConfirmMigrationStep(),
new RewriteCsprojsStep(),
+ new BumpDotNetVersionStep(),
new RewriteCsFilesStep(),
new RewriteBootstrapScriptsStep(),
new RenameNukeDirectoryStep()
];
///
- /// Runs every step in against a fresh .
+ /// Runs every step in against a fresh , stopping
+ /// early if (or any later step) sets .
///
/// A of the files changed, edits made, and any warnings.
public async Task RunAsync()
@@ -42,6 +46,10 @@ public async Task RunAsync()
foreach (var step in steps)
{
await step.ExecuteAsync(context, summary);
+ if (summary.Cancelled)
+ {
+ break;
+ }
}
return summary;
diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs
new file mode 100644
index 000000000..163f3679a
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Bumps the repo's build orchestrator project (_build.csproj) to target
+/// and pins global.json's SDK version to
+/// — but only when what's there is behind those minimums. Already
+/// up-to-date or newer values (e.g. a build project already on net11.0) are left alone.
+///
+internal sealed class BumpDotNetVersionStep : IMigrationStep
+{
+ ///
+ /// The .NET target framework moniker to bump _build.csproj to when it's behind.
+ ///
+ private const string TargetFramework = "net10.0";
+
+ ///
+ /// The .NET SDK version to pin global.json to when it's behind.
+ ///
+ private const string SdkVersion = "10.0.100";
+
+ ///
+ /// The minimum .NET SDK version. Versions at or above this aren't touched.
+ ///
+ private static readonly Version minimumSupportedSdkVersion = new(10, 0, 100);
+
+ ///
+ /// The minimum .NET target framework major version, derived from .
+ /// Monikers at or above this aren't touched. Shared with
+ /// so the minimum is only defined once.
+ ///
+ internal static readonly int MinimumSupportedMajor = TargetFrameworkMonikers.ExtractMajor(TargetFramework);
+
+ ///
+ /// Matches the TargetFramework element's raw value. Build projects don't multi-target,
+ /// so TargetFrameworks (plural) is intentionally not matched here.
+ ///
+ private static readonly Regex targetFrameworkElementPattern = new(
+ @"(?[^<]+)",
+ RegexOptions.Compiled);
+
+ ///
+ /// Matches the value of global.json's sdk.version property.
+ ///
+ private static readonly Regex sdkVersionPattern = new(
+ @"(?<=""sdk""\s*:\s*\{[^}]*?""version""\s*:\s*"")[^""]+",
+ RegexOptions.Compiled | RegexOptions.Singleline);
+
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj"))
+ {
+ MigrationFileOperations.ApplyRewrite(context, path, BumpTargetFramework, summary);
+ }
+
+ foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "global.json"))
+ {
+ MigrationFileOperations.ApplyRewrite(context, path, BumpSdkVersion, summary);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Rewrites a build project's TargetFramework element to
+ /// when its current moniker is behind .
+ ///
+ /// The original _build.csproj content.
+ /// The rewritten content and the number of edits made.
+ public static RewriteResult BumpTargetFramework(string original)
+ {
+ Match match = targetFrameworkElementPattern.Match(original);
+ if (!match.Success ||
+ !TargetFrameworkMonikers.IsOlderThanMinimumSupported(match.Groups["value"].Value, MinimumSupportedMajor))
+ {
+ return new RewriteResult(original, 0);
+ }
+
+ string content = targetFrameworkElementPattern.Replace(
+ original,
+ $"{TargetFramework}",
+ count: 1);
+
+ return new RewriteResult(content, 1);
+ }
+
+ ///
+ /// Rewrites global.json's sdk.version to only when the
+ /// current version is behind .
+ ///
+ /// The original global.json content.
+ /// The rewritten content and the number of edits made.
+ public static RewriteResult BumpSdkVersion(string original)
+ {
+ Match match = sdkVersionPattern.Match(original);
+ if (!match.Success || !IsOlderThanMinimumSupportedSdk(match.Value))
+ {
+ return new RewriteResult(original, 0);
+ }
+
+ string content = sdkVersionPattern.Replace(original, SdkVersion, count: 1);
+ return new RewriteResult(content, 1);
+ }
+
+ ///
+ /// Returns true when is behind
+ /// , or can't be parsed as a version at all.
+ ///
+ /// The sdk.version value from global.json.
+ private static bool IsOlderThanMinimumSupportedSdk(string version)
+ {
+ Version parsed;
+ if (!Version.TryParse(version, out parsed))
+ {
+ return true;
+ }
+
+ return parsed < minimumSupportedSdkVersion;
+ }
+}
diff --git a/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs
new file mode 100644
index 000000000..b67bbc629
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Fallout.Migrate.Common;
+using Spectre.Console;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Asks the user to confirm before any subsequent step writes to disk. Runs last among the
+/// read-only/advisory steps, immediately before — the first step
+/// in that mutates files. Sets when
+/// the user declines, which checks to stop early.
+///
+internal sealed class ConfirmMigrationStep : IMigrationStep
+{
+ ///
+ public async Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ if (context.DryRun)
+ {
+ // Nothing will be written; no point confirming.
+ return;
+ }
+
+ if (Console.IsInputRedirected)
+ {
+ // Non-interactive (CI, piped input, automated tests) — nothing to prompt against, so
+ // proceed as if confirmed.
+ return;
+ }
+
+ var prompt = new ConfirmationPrompt(
+ $"This will modify files under [blue]{context.RootDirectory}[/]. Continue?");
+
+ bool proceed = await prompt.ShowAsync(AnsiConsole.Console, CancellationToken.None);
+ if (!proceed)
+ {
+ summary.Cancelled = true;
+ AnsiConsole.MarkupLine("[yellow]Migration cancelled — no files were changed.[/]");
+ }
+ }
+}
diff --git a/src/Fallout.Migrate/Steps/CsprojRewriter.cs b/src/Fallout.Migrate/Steps/CsprojRewriter.cs
deleted file mode 100644
index 15ddae94f..000000000
--- a/src/Fallout.Migrate/Steps/CsprojRewriter.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-using System.Text.RegularExpressions;
-using Fallout.Migrate.Common;
-
-namespace Fallout.Migrate.Steps;
-
-///
-/// Rewrites .csproj files: Nuke.* package/project references become Fallout.*
-/// (pinning the current Fallout version where an inline Version attribute was present),
-/// Nuke* MSBuild properties are renamed to Fallout*, and stale explicit
-/// System.Security.Cryptography.Xml pins are stripped. Driven by .
-///
-internal static class CsprojRewriter
-{
- // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X
- // at the current Fallout version. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as
- // Fallout.* packages and produce NU1603 ("not found, falling back to next-higher") which
- // `WarningsAsErrors` in the migrated project escalates. Bumping in the same pass avoids
- // a broken post-migrate build (#217). Tolerates extra attributes between Include and Version
- // (e.g. `PrivateAssets="all"`).
- private static readonly Regex nukePackageWithInlineVersionPattern = new(
- @"(?[A-Z][A-Za-z0-9.]+)(?""[^>]*?\s+Version="")[^""]+",
- RegexOptions.Compiled);
-
- // PackageReference / ProjectReference `Include="Nuke.X"` → `Include="Fallout.X"` — namespace
- // only. Catches references that DON'T have an inline Version (central package management).
- // Must run AFTER NukePackageWithInlineVersionPattern so it only touches what's left.
- private static readonly Regex packageReferencePattern =
- new(@"(?<=\b(?:Include|Update|Remove)="")Nuke\.(?=[A-Z])", RegexOptions.Compiled);
-
- // MSBuild element/property names that begin with `Nuke` followed by an uppercase
- // letter (e.g. ...). Limited to known consumer-facing names from
- // P3.5b so we don't rewrite unrelated user-defined identifiers that happen to start
- // with the literal "Nuke".
- private static readonly Regex msBuildPropertyPattern = new(
- @"\bNuke(?=" +
- "(?:RootDirectory|ScriptDirectory|TelemetryVersion|BaseDirectory|BaseNamespace|" +
- "UseNestedNamespaces|RepositoryUrl|UpdateReferences|ContinueOnError|TaskTimeout|" +
- "Timeout|TasksEnabled|DefaultExcludes|ExcludeBoot|ExcludeConfig|ExcludeLogs|" +
- "ExcludeDirectoryBuild|ExcludeCi|SpecificationFiles|ExternalFiles|TasksAssembly|" +
- "TasksDirectory)\\b)",
- RegexOptions.Compiled);
-
- // Strip explicit `System.Security.Cryptography.Xml` PackageReferences. NUKE-era projects
- // often pinned this directly at an older major (e.g. 9.x). Fallout.Common 10.2.12+ transitively
- // requires a newer version (10.0.6+) and the conflict trips NU1605 ("Detected package
- // downgrade"). Removing the explicit pin lets the transitive version win, which is what the
- // migrated project wants (#217). Matches a self-closing element with optional surrounding
- // indentation + trailing newline.
- private static readonly Regex cryptographyXmlPackageRefPattern = new(
- @"^[ \t]*\s*\r?\n?",
- RegexOptions.Compiled | RegexOptions.Multiline);
-
- ///
- /// Rewrites content, replacing Nuke.* references and MSBuild
- /// properties with their Fallout.* equivalents and stripping stale pins.
- ///
- /// The original .csproj file content.
- /// The Fallout version to pin into rewritten inline-versioned references.
- /// The rewritten content and the number of edits made.
- public static RewriteResult Rewrite(string original, string falloutVersion)
- {
- var edits = 0;
- var content = original;
-
- // Pass 1 — combined Include + Version rewrite for Nuke.X PackageReferences with inline Version.
- content = nukePackageWithInlineVersionPattern.Replace(content, m =>
- {
- edits++;
- return m.Groups["prefix"].Value
- + "Fallout." + m.Groups["name"].Value
- + m.Groups["between"].Value
- + falloutVersion;
- });
-
- // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (CPM-managed
- // PackageReferences without inline Version, ProjectReferences, MSBuild properties).
- content = packageReferencePattern.Replace(content, _ =>
- {
- edits++;
- return "Fallout.";
- });
-
- content = msBuildPropertyPattern.Replace(content, _ =>
- {
- edits++;
- return "Fallout";
- });
-
- // Pass 3 — strip the stale System.Security.Cryptography.Xml direct pin.
- content = cryptographyXmlPackageRefPattern.Replace(content, _ =>
- {
- edits++;
- return string.Empty;
- });
-
- return new RewriteResult(content, edits);
- }
-}
diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs
index d51c26a0c..51d5c2c3d 100644
--- a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs
+++ b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs
@@ -1,35 +1,73 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Net.Http;
using System.Reflection;
+using System.Text.Json;
using System.Threading.Tasks;
using Fallout.Migrate.Common;
+using Spectre.Console;
namespace Fallout.Migrate.Steps;
// Pinned into migrated `` lines.
-// Uses the running migrate tool's own SemVer (Nerdbank.GitVersioning, set on
+// Prefers the latest published, non-prerelease Fallout.Common version from NuGet within the
+// running tool's own major (calendar year), so a migration always pins to what's actually
+// installable without jumping to a newer, potentially breaking yearly major the tool wasn't
+// built against. Falls back to the running migrate tool's own SemVer (Nerdbank.GitVersioning,
+// set on AssemblyInformationalVersion) when NuGet can't be reached (offline, corporate proxy,
+// etc.) or has no matching-major stable release yet. For dev/local builds without a `+` in
+// InformationalVersion (i.e. no build-metadata suffix), falls back further to a
+// known-published floor so we never emit a bogus pin like Version="LOCAL". Inlined to keep
+// Fallout.Migrate dependency-free.
-// AssemblyInformationalVersion) so the migration output aligns with the tool the user
-
-// just installed. For dev/local builds without a `+` in InformationalVersion (i.e. no
-
-// build-metadata suffix), falls back to a known-published floor so we never emit a
-
-// bogus pin like Version="LOCAL". Inlined to keep Fallout.Migrate dependency-free.
///
-/// Resolves the running tool's own Fallout version and stores it on .
+/// Resolves the Fallout version to pin and stores it on .
/// Must run first in : reads the
/// resolved version to pin rewritten package references.
///
internal sealed class ResolveFalloutVersionStep : IMigrationStep
{
- /// The version to fall back to when the assembly carries no build-metadata suffix.
+ ///
+ /// The version to fall back to when the assembly carries no build-metadata suffix.
+ ///
private const string Fallback = "10.3.49";
+ ///
+ /// The NuGet package whose latest stable version is used to pin migrated references.
+ ///
+ private const string PackageId = "fallout.common";
+
+ ///
+ /// NuGet's flat-container index for : a JSON document listing every
+ /// published version (stable and prerelease), oldest first.
+ ///
+ private static readonly Uri flatContainerIndex = new($"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json");
+
+ private static readonly HttpClient httpClient = CreateHttpClient();
+
///
- public Task ExecuteAsync(MigrationContext context, Summary summary)
+ public async Task ExecuteAsync(MigrationContext context, Summary summary)
{
- context.FalloutVersion = Resolve();
- return Task.CompletedTask;
+ string localVersion = ResolveFromAssembly();
+ int localMajor = ExtractMajor(localVersion);
+
+ string nuGetVersion = await ResolveFromNuGetAsync(localMajor);
+ if (nuGetVersion != null)
+ {
+ AnsiConsole.MarkupLineInterpolated(
+ $"[grey]Resolved Fallout version [bold]{nuGetVersion}[/] from NuGet (latest stable {localMajor}.x release).[/]");
+
+ context.FalloutVersion = nuGetVersion;
+ }
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated(
+ $"[grey]No stable {localMajor}.x release found on NuGet; using the running tool's own version [bold]{localVersion}[/].[/]");
+
+ context.FalloutVersion = localVersion;
+ }
}
///
@@ -37,9 +75,9 @@ public Task ExecuteAsync(MigrationContext context, Summary summary)
/// build-metadata suffix, falling back to when none is present.
///
/// The Fallout version to pin into rewritten package references.
- private static string Resolve()
+ private static string ResolveFromAssembly()
{
- var informational = typeof(ResolveFalloutVersionStep).Assembly
+ string informational = typeof(ResolveFalloutVersionStep).Assembly
.GetCustomAttribute()
?.InformationalVersion;
@@ -56,4 +94,86 @@ private static string Resolve()
return informational[..plusIndex];
}
+
+ ///
+ /// Extracts the major version segment (e.g. the calendar year) from a version string, ignoring
+ /// any prerelease or build-metadata suffix.
+ ///
+ /// A version string such as "2026.1.0-preview.134".
+ /// The parsed major version.
+ private static int ExtractMajor(string version)
+ {
+ int dotIndex = version.IndexOf('.');
+ string majorSegment = dotIndex == -1 ? version : version[..dotIndex];
+
+ return int.Parse(majorSegment, CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// Queries NuGet for the latest non-prerelease version of whose major
+ /// version matches .
+ ///
+ /// The major version (calendar year) to restrict the lookup to.
+ /// The latest published stable version within , or null
+ /// if NuGet couldn't be reached or has no matching-major stable version.
+ private static async Task ResolveFromNuGetAsync(int major)
+ {
+ try
+ {
+ using HttpResponseMessage response = await httpClient.GetAsync(flatContainerIndex);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ using Stream stream = await response.Content.ReadAsStreamAsync();
+ using JsonDocument document = await JsonDocument.ParseAsync(stream);
+ if (!document.RootElement.TryGetProperty("versions", out JsonElement versions))
+ {
+ return null;
+ }
+
+ Version latest = null;
+ foreach (JsonElement element in versions.EnumerateArray())
+ {
+ string raw = element.GetString();
+
+ // Skip prerelease versions (e.g. "2026.1.0-preview.12+g1a2b3c") — only stable
+ // releases are pinned into migrated package references.
+ if (string.IsNullOrEmpty(raw) || raw.Contains('-'))
+ {
+ continue;
+ }
+
+ if (Version.TryParse(raw, out Version parsed) && parsed.Major == major &&
+ (latest is null || parsed > latest))
+ {
+ latest = parsed;
+ }
+ }
+
+ return latest?.ToString();
+ }
+ catch (Exception ex)
+ {
+ // Best-effort: offline, proxy, DNS failure, malformed response, etc. all fall back
+ // to the running tool's own version above.
+ AnsiConsole.MarkupLineInterpolated($"[grey]NuGet lookup failed ({ex.Message}); falling back.[/]");
+ return null;
+ }
+ }
+
+ ///
+ /// Creates the used for the NuGet flat-container lookup, identifying
+ /// this tool via a User-Agent header so NuGet.org doesn't treat the request as anonymous
+ /// script traffic.
+ ///
+ /// A configured .
+ private static HttpClient CreateHttpClient()
+ {
+ HttpClient client = new() { Timeout = TimeSpan.FromSeconds(3) };
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("fallout-migrate");
+
+ return client;
+ }
}
diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
index aed7ea78b..b462a45cf 100644
--- a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
+++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
@@ -1,13 +1,56 @@
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Fallout.Migrate.Common;
namespace Fallout.Migrate.Steps;
///
-/// Rewrites every *.csproj file under the repository root via .
+/// Rewrites every *.csproj file under the repository root: Nuke.* package/project
+/// references become Fallout.* (pinning the current Fallout version where an inline
+/// Version attribute was present), Nuke* MSBuild properties are renamed to
+/// Fallout*, and stale explicit System.Security.Cryptography.Xml pins are stripped.
///
internal sealed class RewriteCsprojsStep : IMigrationStep
{
+ // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X
+ // at the current Fallout version. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as
+ // Fallout.* packages and produce NU1603 ("not found, falling back to next-higher") which
+ // `WarningsAsErrors` in the migrated project escalates. Bumping in the same pass avoids
+ // a broken post-migrate build (#217). Tolerates extra attributes between Include and Version
+ // (e.g. `PrivateAssets="all"`).
+ private static readonly Regex nukePackageWithInlineVersionPattern = new(
+ @"(?[A-Z][A-Za-z0-9.]+)(?""[^>]*?\s+Version="")[^""]+",
+ RegexOptions.Compiled);
+
+ // PackageReference / ProjectReference `Include="Nuke.X"` → `Include="Fallout.X"` — namespace
+ // only. Catches references that DON'T have an inline Version (central package management).
+ // Must run AFTER NukePackageWithInlineVersionPattern so it only touches what's left.
+ private static readonly Regex packageReferencePattern =
+ new(@"(?<=\b(?:Include|Update|Remove)="")Nuke\.(?=[A-Z])", RegexOptions.Compiled);
+
+ // MSBuild element/property names that begin with `Nuke` followed by an uppercase
+ // letter (e.g. ...). Limited to known consumer-facing names from
+ // P3.5b so we don't rewrite unrelated user-defined identifiers that happen to start
+ // with the literal "Nuke".
+ private static readonly Regex msBuildPropertyPattern = new(
+ @"\bNuke(?=" +
+ "(?:RootDirectory|ScriptDirectory|TelemetryVersion|BaseDirectory|BaseNamespace|" +
+ "UseNestedNamespaces|RepositoryUrl|UpdateReferences|ContinueOnError|TaskTimeout|" +
+ "Timeout|TasksEnabled|DefaultExcludes|ExcludeBoot|ExcludeConfig|ExcludeLogs|" +
+ "ExcludeDirectoryBuild|ExcludeCi|SpecificationFiles|ExternalFiles|TasksAssembly|" +
+ "TasksDirectory)\\b)",
+ RegexOptions.Compiled);
+
+ // Strip explicit `System.Security.Cryptography.Xml` PackageReferences. NUKE-era projects
+ // often pinned this directly at an older major (e.g. 9.x). Fallout.Common 10.2.12+ transitively
+ // requires a newer version (10.0.6+) and the conflict trips NU1605 ("Detected package
+ // downgrade"). Removing the explicit pin lets the transitive version win, which is what the
+ // migrated project wants (#217). Matches a self-closing element with optional surrounding
+ // indentation + trailing newline.
+ private static readonly Regex cryptographyXmlPackageRefPattern = new(
+ @"^[ \t]*\s*\r?\n?",
+ RegexOptions.Compiled | RegexOptions.Multiline);
+
///
public Task ExecuteAsync(MigrationContext context, Summary summary)
{
@@ -16,10 +59,56 @@ public Task ExecuteAsync(MigrationContext context, Summary summary)
MigrationFileOperations.ApplyRewrite(
context,
path,
- content => CsprojRewriter.Rewrite(content, context.FalloutVersion),
+ content => Rewrite(content, context.FalloutVersion),
summary);
}
return Task.CompletedTask;
}
+
+ ///
+ /// Rewrites content, replacing Nuke.* references and MSBuild
+ /// properties with their Fallout.* equivalents and stripping stale pins.
+ ///
+ /// The original .csproj file content.
+ /// The Fallout version to pin into rewritten inline-versioned references.
+ /// The rewritten content and the number of edits made.
+ public static RewriteResult Rewrite(string original, string falloutVersion)
+ {
+ var edits = 0;
+ var content = original;
+
+ // Pass 1 — combined Include + Version rewrite for Nuke.X PackageReferences with inline Version.
+ content = nukePackageWithInlineVersionPattern.Replace(content, m =>
+ {
+ edits++;
+ return m.Groups["prefix"].Value
+ + "Fallout." + m.Groups["name"].Value
+ + m.Groups["between"].Value
+ + falloutVersion;
+ });
+
+ // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (CPM-managed
+ // PackageReferences without inline Version, ProjectReferences, MSBuild properties).
+ content = packageReferencePattern.Replace(content, _ =>
+ {
+ edits++;
+ return "Fallout.";
+ });
+
+ content = msBuildPropertyPattern.Replace(content, _ =>
+ {
+ edits++;
+ return "Fallout";
+ });
+
+ // Pass 3 — strip the stale System.Security.Cryptography.Xml direct pin.
+ content = cryptographyXmlPackageRefPattern.Replace(content, _ =>
+ {
+ edits++;
+ return string.Empty;
+ });
+
+ return new RewriteResult(content, edits);
+ }
}
diff --git a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs
new file mode 100644
index 000000000..961d5eca1
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs
@@ -0,0 +1,59 @@
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Fallout.Common.IO;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Warns when the repo's build orchestrator project (_build.csproj) targets an older .NET
+/// than Fallout requires. Fallout's own tooling is built and tested against .NET 10; a build
+/// project still on an older TFM can hit tool incompatibilities that aren't caught by this
+/// migration's other, purely textual rewrites.
+///
+internal sealed class VerifyBuildTargetFrameworkStep : IMigrationStep
+{
+ ///
+ /// Matches a `TargetFramework` or `TargetFrameworks` element's raw value.
+ ///
+ private static readonly Regex targetFrameworkElementPattern = new(
+ @"(?[^<]+)",
+ RegexOptions.Compiled);
+
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ foreach (AbsolutePath path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj"))
+ {
+ string content = path.ReadAllText();
+ Match match = targetFrameworkElementPattern.Match(content);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ string[] monikers = match.Groups["value"].Value
+ .Split(';')
+ .Select(m => m.Trim())
+ .Where(m => m.Length > 0)
+ .ToArray();
+
+ string[] outdated = monikers
+ .Where(moniker => TargetFrameworkMonikers.IsOlderThanMinimumSupported(moniker, BumpDotNetVersionStep.MinimumSupportedMajor))
+ .ToArray();
+ if (outdated.Length == 0)
+ {
+ continue;
+ }
+
+ summary.Warnings.Add(
+ $"{MigrationFileOperations.RelativePath(context.RootDirectory, path)} targets " +
+ $"{string.Join(", ", outdated)}, older than .NET {BumpDotNetVersionStep.MinimumSupportedMajor}. " +
+ "Check that all tools you invoke from the build (SDKs, global tools, etc.) also " +
+ $"support .NET {BumpDotNetVersionStep.MinimumSupportedMajor} before finishing this migration.");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs b/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs
new file mode 100644
index 000000000..a294a6f15
--- /dev/null
+++ b/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs
@@ -0,0 +1,115 @@
+using FluentAssertions;
+using Xunit;
+using Fallout.Migrate.Steps;
+
+namespace Fallout.Migrate.Specs;
+
+public class BumpDotNetVersionStepSpecs
+{
+ [Fact]
+ public void BumpsOlderTargetFrameworkToNet10()
+ {
+ const string input = """
+
+
+ Exe
+ net8.0
+
+
+ """;
+
+ var result = BumpDotNetVersionStep.BumpTargetFramework(input);
+
+ result.EditCount.Should().Be(1);
+ result.Content.Should().Contain("net10.0");
+ result.Content.Should().NotContain("net8.0");
+ }
+
+ [Fact]
+ public void LeavesAlreadyNet10Unchanged()
+ {
+ const string input = """
+
+
+ net10.0
+
+
+ """;
+
+ var result = BumpDotNetVersionStep.BumpTargetFramework(input);
+
+ result.EditCount.Should().Be(0);
+ result.Content.Should().Be(input);
+ }
+
+ [Fact]
+ public void LeavesNewerTargetFrameworkUnchanged()
+ {
+ const string input = """
+
+
+ net11.0
+
+
+ """;
+
+ var result = BumpDotNetVersionStep.BumpTargetFramework(input);
+
+ result.EditCount.Should().Be(0);
+ result.Content.Should().Be(input);
+ }
+
+ [Fact]
+ public void BumpsSdkVersionInGlobalJson()
+ {
+ const string input = """
+ {
+ "sdk": {
+ "version": "8.0.100",
+ "rollForward": "latestMinor"
+ }
+ }
+ """;
+
+ var result = BumpDotNetVersionStep.BumpSdkVersion(input);
+
+ result.EditCount.Should().Be(1);
+ result.Content.Should().Contain(@"""version"": ""10.0.100""");
+ result.Content.Should().Contain(@"""rollForward"": ""latestMinor""");
+ result.Content.Should().NotContain("8.0.100");
+ }
+
+ [Fact]
+ public void LeavesAlreadyPinnedSdkVersionUnchanged()
+ {
+ const string input = """
+ {
+ "sdk": {
+ "version": "10.0.100"
+ }
+ }
+ """;
+
+ var result = BumpDotNetVersionStep.BumpSdkVersion(input);
+
+ result.EditCount.Should().Be(0);
+ result.Content.Should().Be(input);
+ }
+
+ [Fact]
+ public void LeavesNewerSdkVersionUnchanged()
+ {
+ const string input = """
+ {
+ "sdk": {
+ "version": "11.0.100"
+ }
+ }
+ """;
+
+ var result = BumpDotNetVersionStep.BumpSdkVersion(input);
+
+ result.EditCount.Should().Be(0);
+ result.Content.Should().Be(input);
+ }
+}
diff --git a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs
index 4430a5fb1..53a937e8e 100644
--- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs
@@ -95,6 +95,56 @@ public async Task WarnsWhenBothNukeAndFalloutDirectoriesExist()
}
}
+ [Fact]
+ public async Task WarnsWhenBuildProjectTargetsOlderThanNet10()
+ {
+ var temp = CreateVanillaFixture();
+ var buildCsprojPath = Path.Combine(temp, "build", "_build.csproj");
+ File.WriteAllText(buildCsprojPath, File.ReadAllText(buildCsprojPath).Replace("net10.0", "net8.0"));
+
+ try
+ {
+ var summary = await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync();
+
+ summary.Warnings.Should().Contain(w =>
+ w.Contains("net8.0") && w.Contains(".NET 10") && w.Contains("_build.csproj"));
+ }
+ finally
+ {
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task BumpsDotNetVersionAndSdk()
+ {
+ var temp = CreateVanillaFixture();
+ var buildCsprojPath = Path.Combine(temp, "build", "_build.csproj");
+ File.WriteAllText(buildCsprojPath, File.ReadAllText(buildCsprojPath).Replace("net10.0", "net8.0"));
+ File.WriteAllText(Path.Combine(temp, "global.json"), """
+ {
+ "sdk": {
+ "version": "8.0.100",
+ "rollForward": "latestMinor"
+ }
+ }
+ """);
+
+ try
+ {
+ await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync();
+
+ File.ReadAllText(buildCsprojPath).Should().Contain("net10.0");
+
+ var globalJson = File.ReadAllText(Path.Combine(temp, "global.json"));
+ globalJson.Should().Contain(@"""version"": ""10.0.100""");
+ }
+ finally
+ {
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
private static string CreateVanillaFixture()
{
var dir = Path.Combine(Path.GetTempPath(), "fallout-migrate-test-" + Guid.NewGuid().ToString("N")[..8]);
@@ -106,7 +156,7 @@ private static string CreateVanillaFixture()
Exe
- net8.0
+ net10.0
.\..
1
diff --git a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs
similarity index 90%
rename from tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs
rename to tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs
index 5f5f07731..e11732f9f 100644
--- a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs
@@ -4,7 +4,7 @@
namespace Fallout.Migrate.Specs;
-public class CsprojRewriterSpecs
+public class RewriteCsprojsStepSpecs
{
private const string TestFalloutVersion = "11.0.0";
@@ -20,7 +20,7 @@ public void RewritesPackageReferenceNamespace()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.EditCount.Should().Be(2);
result.Content.Should().Contain(@"Include=""Fallout.Common""");
@@ -40,7 +40,7 @@ public void RewritesNukeRootDirectoryProperty()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.EditCount.Should().Be(4); // 2 opening + 2 closing tags
result.Content.Should().Contain("");
@@ -60,7 +60,7 @@ public void LeavesUnrelatedNukePrefixedIdentifiersAlone()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.EditCount.Should().Be(0);
result.Content.Should().Be(input);
@@ -77,7 +77,7 @@ public void ReturnsZeroEditsForUnchangedContent()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.EditCount.Should().Be(0);
result.Content.Should().Be(input);
@@ -97,7 +97,7 @@ public void BumpsNukeInlineVersionToCurrentFalloutVersion()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.Content.Should().Contain(@"Include=""Fallout.Common"" Version=""11.0.0""");
result.Content.Should().Contain(@"Include=""Fallout.Components"" Version=""11.0.0""");
@@ -117,7 +117,7 @@ public void BumpsVersionAcrossExtraAttributesBetweenIncludeAndVersion()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.Content.Should().Contain(@"Include=""Fallout.Common"" PrivateAssets=""all"" Version=""11.0.0""");
}
@@ -136,7 +136,7 @@ public void LeavesCpmManagedReferencesWithoutInlineVersionUntouchedByVersionPass
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.Content.Should().Contain(@"");
result.Content.Should().NotContain(@"Version=");
@@ -157,7 +157,7 @@ public void StripsSystemSecurityCryptographyXmlPackageReference()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.Content.Should().NotContain("System.Security.Cryptography.Xml");
result.Content.Should().Contain(@"Include=""Fallout.Common"" Version=""11.0.0""");
@@ -178,7 +178,7 @@ public void LeavesOtherSystemPackagesAlone()
""";
- var result = CsprojRewriter.Rewrite(input, TestFalloutVersion);
+ var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion);
result.EditCount.Should().Be(0);
result.Content.Should().Be(input);
diff --git a/version.json b/version.json
index b5a80df54..fca891223 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
- "version": "2026.1.0-preview.{height}",
+ "version": "10.0.0-preview.{height}",
"publicReleaseRefSpec": [
"^refs/heads/release/\\d{4}$",
"^refs/heads/support/\\d{4}$",