From 0162a779e2ce903c2acb2ce88073dfd237f70c7b Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:54:34 +0200 Subject: [PATCH 1/9] Bump Fallout.Migrate to the 10.x version line Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Fallout.Migrate.csproj | 1 + version.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/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}$", From b615bf1362fd70a978ca5dfc5095ae0a8496c6d4 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:54:55 +0200 Subject: [PATCH 2/9] Resolve Fallout.Common version from NuGet, scoped to the tool's own major ResolveFalloutVersionStep now queries NuGet's flat-container index for the latest non-prerelease Fallout.Common release matching the running tool's own major (calendar year), instead of only reading the tool's own AssemblyInformationalVersion. This keeps a migration pinned to what's actually installable without jumping to a newer, potentially breaking yearly major the tool wasn't built against. Falls back to the tool's own version when NuGet can't be reached or has no matching-major stable release yet, and logs via AnsiConsole which path was taken so the resolved version is never a silent guess. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Steps/ResolveFalloutVersionStep.cs | 150 ++++++++++++++++-- 1 file changed, 135 insertions(+), 15 deletions(-) diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs index d51c26a0c..d66dab7f0 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; + } } From f6145f51d0f2cd1b2a2a9ccde6c19b18fcd761a8 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:55:24 +0200 Subject: [PATCH 3/9] Warn when the build project targets an older TFM than .NET 10 Fallout's tooling is built and tested against .NET 10; a build project still on an older TFM can hit tool incompatibilities that this migration's other, purely textual rewrites won't catch. VerifyBuildTargetFrameworkStep reads _build.csproj's TargetFramework(s) and adds a warning for any moniker that isn't a modern net10.0+ (catching net8.0, net48, netstandard2.0, netcoreapp3.1, etc.). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Migration.cs | 1 + .../Steps/VerifyBuildTargetFrameworkStep.cs | 85 +++++++++++++++++++ .../MigrationIntegrationSpecs.cs | 22 ++++- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs diff --git a/src/Fallout.Migrate/Migration.cs b/src/Fallout.Migrate/Migration.cs index d54d854e8..53ea38c75 100644 --- a/src/Fallout.Migrate/Migration.cs +++ b/src/Fallout.Migrate/Migration.cs @@ -24,6 +24,7 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri private static readonly IReadOnlyList steps = [ new ResolveFalloutVersionStep(), + new VerifyBuildTargetFrameworkStep(), new RewriteCsprojsStep(), new RewriteCsFilesStep(), new RewriteBootstrapScriptsStep(), diff --git a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs new file mode 100644 index 000000000..18531d5f0 --- /dev/null +++ b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +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 +{ + /// + /// The minimum .NET major version Fallout's tooling targets. + /// + private const int MinimumSupportedMajor = 10; + + /// + /// Matches a `TargetFramework` or `TargetFrameworks` element's raw value. + /// + private static readonly Regex targetFrameworkElementPattern = new( + @"(?[^<]+)", + RegexOptions.Compiled); + + /// + /// 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); + + /// + public void Execute(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(IsOlderThanMinimumSupported).ToArray(); + if (outdated.Length == 0) + { + continue; + } + + summary.Warnings.Add( + $"{MigrationFileOperations.RelativePath(context.RootDirectory, path)} targets " + + $"{string.Join(", ", outdated)}, older than .NET {MinimumSupportedMajor}. " + + "Check that all tools you invoke from the build (SDKs, global tools, etc.) also " + + $"support .NET {MinimumSupportedMajor} before finishing this migration."); + } + } + + /// + /// Returns true when targets an older .NET than + /// — including non-modern monikers (.NET Framework, + /// .NET Standard, out-of-support netcoreapp*) which never satisfy the minimum. + /// + /// A single target framework moniker, e.g. net8.0. + private static bool IsOlderThanMinimumSupported(string moniker) + { + Match match = modernMonikerPattern.Match(moniker); + if (!match.Success) + { + return true; + } + + int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); + return major < MinimumSupportedMajor; + } +} diff --git a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs index 4430a5fb1..c6d1ec57a 100644 --- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs +++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs @@ -95,6 +95,26 @@ public async Task WarnsWhenBothNukeAndFalloutDirectoriesExist() } } + [Fact] + public void 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 = new Migration(temp, dryRun: false, TextWriter.Null).Run(); + + summary.Warnings.Should().Contain(w => + w.Contains("net8.0") && w.Contains(".NET 10") && w.Contains("_build.csproj")); + } + 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 +126,7 @@ private static string CreateVanillaFixture() Exe - net8.0 + net10.0 .\.. 1 From 8850126fda650814a6adfaebeeda9d3a966990e3 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:55:37 +0200 Subject: [PATCH 4/9] Ask for confirmation before the migration writes any files ConfirmMigrationStep runs immediately before RewriteCsprojsStep, the first step that mutates files, and prompts via AnsiConsole.Confirm before letting the migration proceed. Declining sets the new Summary.Cancelled flag, which Migration.Run checks to stop executing further steps, and MigrateCommand returns exit code 1 without printing the (now meaningless) summary. The prompt is skipped for --dry-run (nothing would be written) and when stdin is redirected (CI, piped input, automated tests) since there's nothing to prompt against. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Common/Summary.cs | 6 +++ src/Fallout.Migrate/MigrateCommand.cs | 5 +++ src/Fallout.Migrate/Migration.cs | 8 +++- .../Steps/ConfirmMigrationStep.cs | 40 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs diff --git a/src/Fallout.Migrate/Common/Summary.cs b/src/Fallout.Migrate/Common/Summary.cs index c554568d7..fcfe677be 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/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 53ea38c75..2d9b82a11 100644 --- a/src/Fallout.Migrate/Migration.cs +++ b/src/Fallout.Migrate/Migration.cs @@ -25,6 +25,7 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri [ new ResolveFalloutVersionStep(), new VerifyBuildTargetFrameworkStep(), + new ConfirmMigrationStep(), new RewriteCsprojsStep(), new RewriteCsFilesStep(), new RewriteBootstrapScriptsStep(), @@ -32,7 +33,8 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri ]; /// - /// 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() @@ -43,6 +45,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/ConfirmMigrationStep.cs b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs new file mode 100644 index 000000000..44711af64 --- /dev/null +++ b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs @@ -0,0 +1,40 @@ +using System; +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 void Execute(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; + } + + bool proceed = AnsiConsole.Confirm( + $"This will modify files under [blue]{context.RootDirectory}[/]. Continue?"); + + if (!proceed) + { + summary.Cancelled = true; + AnsiConsole.MarkupLine("[yellow]Migration cancelled — no files were changed.[/]"); + } + } +} From 571a78554d60cedf0077b3f01670c8e784d6c560 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:55:52 +0200 Subject: [PATCH 5/9] Fold CsprojRewriter into RewriteCsprojsStep CsprojRewriter.Rewrite had a single caller (RewriteCsprojsStep). Move its regex fields and Rewrite method directly into the step and delete the now-empty class. Rename the spec file/class to match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Steps/CsprojRewriter.cs | 98 ------------------- .../Steps/RewriteCsprojsStep.cs | 93 +++++++++++++++++- ...terSpecs.cs => RewriteCsprojsStepSpecs.cs} | 20 ++-- 3 files changed, 101 insertions(+), 110 deletions(-) delete mode 100644 src/Fallout.Migrate/Steps/CsprojRewriter.cs rename tests/Fallout.Migrate.Specs/{CsprojRewriterSpecs.cs => RewriteCsprojsStepSpecs.cs} (90%) 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/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/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); From 0e25d72c8907c4954f8ea3562ca4d83528eb1ce1 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:56:11 +0200 Subject: [PATCH 6/9] Bump build project to net10.0 and global.json SDK to 10.0.100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BumpDotNetVersionStep: rewrites _build.csproj's TargetFramework and global.json's sdk.version to net10.0 / 10.0.100 during migration, matching the versions Fallout's own tooling targets — but only when what's there is behind those minimums, so an already up-to-date or newer build project is left untouched. Build projects don't multi-target, so only the singular TargetFramework element is matched. The minimum TFM major is parsed from TargetFramework itself rather than duplicated as a separate constant, so there's a single source of truth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Migration.cs | 1 + .../Steps/BumpDotNetVersionStep.cs | 148 ++++++++++++++++++ .../BumpDotNetVersionStepSpecs.cs | 115 ++++++++++++++ .../MigrationIntegrationSpecs.cs | 30 ++++ 4 files changed, 294 insertions(+) create mode 100644 src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs create mode 100644 tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs diff --git a/src/Fallout.Migrate/Migration.cs b/src/Fallout.Migrate/Migration.cs index 2d9b82a11..aa8d6e0ed 100644 --- a/src/Fallout.Migrate/Migration.cs +++ b/src/Fallout.Migrate/Migration.cs @@ -27,6 +27,7 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri new VerifyBuildTargetFrameworkStep(), new ConfirmMigrationStep(), new RewriteCsprojsStep(), + new BumpDotNetVersionStep(), new RewriteCsFilesStep(), new RewriteBootstrapScriptsStep(), new RenameNukeDirectoryStep() diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs new file mode 100644 index 000000000..3628fa0cc --- /dev/null +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -0,0 +1,148 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; +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); + + /// + /// 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); + + /// + /// The minimum .NET target framework major version, derived from . + /// Monikers at or above this aren't touched. + /// + private static readonly int minimumSupportedMajor = int.Parse( + modernMonikerPattern.Match(TargetFramework).Groups["major"].Value, + CultureInfo.InvariantCulture); + + /// + /// 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 void Execute(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); + } + } + + /// + /// 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 || !IsOlderThanMinimumSupportedFramework(match.Groups["value"].Value)) + { + 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 targets an older .NET than + /// — including non-modern monikers (.NET Framework, + /// .NET Standard, out-of-support netcoreapp*) which never satisfy the minimum. + /// + /// A single target framework moniker, e.g. net8.0. + private static bool IsOlderThanMinimumSupportedFramework(string moniker) + { + Match match = modernMonikerPattern.Match(moniker); + if (!match.Success) + { + return true; + } + + int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); + return major < minimumSupportedMajor; + } + + /// + /// 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/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 c6d1ec57a..46636d224 100644 --- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs +++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs @@ -115,6 +115,36 @@ public void WarnsWhenBuildProjectTargetsOlderThanNet10() } } + [Fact] + public void 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 + { + new Migration(temp, dryRun: false, TextWriter.Null).Run(); + + 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]); From a3ae50a07c13aa389c4ad5e50733d9726459b778 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:56:29 +0200 Subject: [PATCH 7/9] Make IMigrationStep and the migration pipeline asynchronous Replace the blocking GetAsync(...).GetAwaiter().GetResult() in ResolveFalloutVersionStep with a proper await. IMigrationStep.Execute is now Task ExecuteAsync; Migration.Run is Migration.RunAsync; MigrateCommand is now an AsyncCommand; Program.Main awaits CommandApp.RunAsync. ConfirmMigrationStep now awaits ConfirmationPrompt.ShowAsync instead of the blocking AnsiConsole.Confirm. Steps with no async work simply return Task.CompletedTask. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Common/Summary.cs | 2 +- .../Steps/BumpDotNetVersionStep.cs | 5 +- .../Steps/ConfirmMigrationStep.cs | 9 ++-- .../Steps/ResolveFalloutVersionStep.cs | 54 +++++++------------ .../Steps/VerifyBuildTargetFrameworkStep.cs | 5 +- .../MigrationIntegrationSpecs.cs | 8 +-- 6 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/Fallout.Migrate/Common/Summary.cs b/src/Fallout.Migrate/Common/Summary.cs index fcfe677be..903dc7e8f 100644 --- a/src/Fallout.Migrate/Common/Summary.cs +++ b/src/Fallout.Migrate/Common/Summary.cs @@ -19,7 +19,7 @@ internal sealed class Summary /// /// Set when the user declined the confirmation prompt in . - /// stops executing further steps once this is set. + /// stops executing further steps once this is set. /// public bool Cancelled { get; set; } diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs index 3628fa0cc..6dba549d8 100644 --- a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Text.RegularExpressions; +using System.Threading.Tasks; using Fallout.Migrate.Common; namespace Fallout.Migrate.Steps; @@ -59,7 +60,7 @@ internal sealed class BumpDotNetVersionStep : IMigrationStep RegexOptions.Compiled | RegexOptions.Singleline); /// - public void Execute(MigrationContext context, Summary summary) + public Task ExecuteAsync(MigrationContext context, Summary summary) { foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj")) { @@ -70,6 +71,8 @@ public void Execute(MigrationContext context, Summary summary) { MigrationFileOperations.ApplyRewrite(context, path, BumpSdkVersion, summary); } + + return Task.CompletedTask; } /// diff --git a/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs index 44711af64..b67bbc629 100644 --- a/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs +++ b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Fallout.Migrate.Common; using Spectre.Console; @@ -8,12 +10,12 @@ 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. +/// the user declines, which checks to stop early. /// internal sealed class ConfirmMigrationStep : IMigrationStep { /// - public void Execute(MigrationContext context, Summary summary) + public async Task ExecuteAsync(MigrationContext context, Summary summary) { if (context.DryRun) { @@ -28,9 +30,10 @@ public void Execute(MigrationContext context, Summary summary) return; } - bool proceed = AnsiConsole.Confirm( + 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; diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs index d66dab7f0..10ee3e44b 100644 --- a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs +++ b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs @@ -45,7 +45,7 @@ internal sealed class ResolveFalloutVersionStep : IMigrationStep /// private static readonly Uri FlatContainerIndex = new($"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json"); - private static readonly HttpClient httpClient = CreateHttpClient(); + private static readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(3) }; /// public async Task ExecuteAsync(MigrationContext context, Summary summary) @@ -70,31 +70,6 @@ public async Task ExecuteAsync(MigrationContext context, Summary summary) } } - /// - /// Reads the tool assembly's and strips the - /// build-metadata suffix, falling back to when none is present. - /// - /// The Fallout version to pin into rewritten package references. - private static string ResolveFromAssembly() - { - string informational = typeof(ResolveFalloutVersionStep).Assembly - .GetCustomAttribute() - ?.InformationalVersion; - - if (string.IsNullOrEmpty(informational)) - { - return Fallback; - } - - int plusIndex = informational.IndexOf('+'); - if (plusIndex == -1) - { - return Fallback; - } - - return informational[..plusIndex]; - } - /// /// Extracts the major version segment (e.g. the calendar year) from a version string, ignoring /// any prerelease or build-metadata suffix. @@ -164,16 +139,27 @@ private static async Task ResolveFromNuGetAsync(int major) } /// - /// 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. + /// Reads the tool assembly's and strips the + /// build-metadata suffix, falling back to when none is present. /// - /// A configured . - private static HttpClient CreateHttpClient() + /// The Fallout version to pin into rewritten package references. + private static string ResolveFromAssembly() { - HttpClient client = new() { Timeout = TimeSpan.FromSeconds(3) }; - client.DefaultRequestHeaders.UserAgent.ParseAdd("fallout-migrate"); + string informational = typeof(ResolveFalloutVersionStep).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (string.IsNullOrEmpty(informational)) + { + return Fallback; + } - return client; + int plusIndex = informational.IndexOf('+'); + if (plusIndex == -1) + { + return Fallback; + } + + return informational[..plusIndex]; } } diff --git a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs index 18531d5f0..528e87eba 100644 --- a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs +++ b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using System.Threading.Tasks; using Fallout.Common.IO; using Fallout.Migrate.Common; @@ -34,7 +35,7 @@ internal sealed class VerifyBuildTargetFrameworkStep : IMigrationStep RegexOptions.Compiled | RegexOptions.IgnoreCase); /// - public void Execute(MigrationContext context, Summary summary) + public Task ExecuteAsync(MigrationContext context, Summary summary) { foreach (AbsolutePath path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj")) { @@ -63,6 +64,8 @@ public void Execute(MigrationContext context, Summary summary) "Check that all tools you invoke from the build (SDKs, global tools, etc.) also " + $"support .NET {MinimumSupportedMajor} before finishing this migration."); } + + return Task.CompletedTask; } /// diff --git a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs index 46636d224..53a937e8e 100644 --- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs +++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs @@ -96,7 +96,7 @@ public async Task WarnsWhenBothNukeAndFalloutDirectoriesExist() } [Fact] - public void WarnsWhenBuildProjectTargetsOlderThanNet10() + public async Task WarnsWhenBuildProjectTargetsOlderThanNet10() { var temp = CreateVanillaFixture(); var buildCsprojPath = Path.Combine(temp, "build", "_build.csproj"); @@ -104,7 +104,7 @@ public void WarnsWhenBuildProjectTargetsOlderThanNet10() try { - var summary = new Migration(temp, dryRun: false, TextWriter.Null).Run(); + 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")); @@ -116,7 +116,7 @@ public void WarnsWhenBuildProjectTargetsOlderThanNet10() } [Fact] - public void BumpsDotNetVersionAndSdk() + public async Task BumpsDotNetVersionAndSdk() { var temp = CreateVanillaFixture(); var buildCsprojPath = Path.Combine(temp, "build", "_build.csproj"); @@ -132,7 +132,7 @@ public void BumpsDotNetVersionAndSdk() try { - new Migration(temp, dryRun: false, TextWriter.Null).Run(); + await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync(); File.ReadAllText(buildCsprojPath).Should().Contain("net10.0"); From 57c10db32975caaad699098b4df6f5639692a4d3 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:56:42 +0200 Subject: [PATCH 8/9] Deduplicate the TFM-minimum check shared by two migration steps Extract the modern-moniker regex and IsOlderThanMinimumSupported logic duplicated between BumpDotNetVersionStep and VerifyBuildTargetFrameworkStep into a shared TargetFrameworkMonikers helper. VerifyBuildTargetFrameworkStep now reads BumpDotNetVersionStep.MinimumSupportedMajor instead of maintaining its own separate constant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Common/TargetFrameworkMonikers.cs | 49 +++++++++++++++++++ .../Steps/BumpDotNetVersionStep.cs | 38 +++----------- .../Steps/VerifyBuildTargetFrameworkStep.cs | 39 ++------------- 3 files changed, 60 insertions(+), 66 deletions(-) create mode 100644 src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs 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/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs index 6dba549d8..163f3679a 100644 --- a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using System.Text.RegularExpressions; using System.Threading.Tasks; using Fallout.Migrate.Common; @@ -29,20 +28,12 @@ internal sealed class BumpDotNetVersionStep : IMigrationStep /// private static readonly Version minimumSupportedSdkVersion = new(10, 0, 100); - /// - /// 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); - /// /// The minimum .NET target framework major version, derived from . - /// Monikers at or above this aren't touched. + /// Monikers at or above this aren't touched. Shared with + /// so the minimum is only defined once. /// - private static readonly int minimumSupportedMajor = int.Parse( - modernMonikerPattern.Match(TargetFramework).Groups["major"].Value, - CultureInfo.InvariantCulture); + internal static readonly int MinimumSupportedMajor = TargetFrameworkMonikers.ExtractMajor(TargetFramework); /// /// Matches the TargetFramework element's raw value. Build projects don't multi-target, @@ -77,14 +68,15 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) /// /// Rewrites a build project's TargetFramework element to - /// when its current moniker is behind . + /// 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 || !IsOlderThanMinimumSupportedFramework(match.Groups["value"].Value)) + if (!match.Success || + !TargetFrameworkMonikers.IsOlderThanMinimumSupported(match.Groups["value"].Value, MinimumSupportedMajor)) { return new RewriteResult(original, 0); } @@ -115,24 +107,6 @@ public static RewriteResult BumpSdkVersion(string original) return new RewriteResult(content, 1); } - /// - /// Returns true when targets an older .NET than - /// — including non-modern monikers (.NET Framework, - /// .NET Standard, out-of-support netcoreapp*) which never satisfy the minimum. - /// - /// A single target framework moniker, e.g. net8.0. - private static bool IsOlderThanMinimumSupportedFramework(string moniker) - { - Match match = modernMonikerPattern.Match(moniker); - if (!match.Success) - { - return true; - } - - int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); - return major < minimumSupportedMajor; - } - /// /// Returns true when is behind /// , or can't be parsed as a version at all. diff --git a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs index 528e87eba..961d5eca1 100644 --- a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs +++ b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -15,11 +14,6 @@ namespace Fallout.Migrate.Steps; /// internal sealed class VerifyBuildTargetFrameworkStep : IMigrationStep { - /// - /// The minimum .NET major version Fallout's tooling targets. - /// - private const int MinimumSupportedMajor = 10; - /// /// Matches a `TargetFramework` or `TargetFrameworks` element's raw value. /// @@ -27,13 +21,6 @@ internal sealed class VerifyBuildTargetFrameworkStep : IMigrationStep @"(?[^<]+)", RegexOptions.Compiled); - /// - /// 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); - /// public Task ExecuteAsync(MigrationContext context, Summary summary) { @@ -52,7 +39,9 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) .Where(m => m.Length > 0) .ToArray(); - string[] outdated = monikers.Where(IsOlderThanMinimumSupported).ToArray(); + string[] outdated = monikers + .Where(moniker => TargetFrameworkMonikers.IsOlderThanMinimumSupported(moniker, BumpDotNetVersionStep.MinimumSupportedMajor)) + .ToArray(); if (outdated.Length == 0) { continue; @@ -60,29 +49,11 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) summary.Warnings.Add( $"{MigrationFileOperations.RelativePath(context.RootDirectory, path)} targets " + - $"{string.Join(", ", outdated)}, older than .NET {MinimumSupportedMajor}. " + + $"{string.Join(", ", outdated)}, older than .NET {BumpDotNetVersionStep.MinimumSupportedMajor}. " + "Check that all tools you invoke from the build (SDKs, global tools, etc.) also " + - $"support .NET {MinimumSupportedMajor} before finishing this migration."); + $"support .NET {BumpDotNetVersionStep.MinimumSupportedMajor} before finishing this migration."); } return Task.CompletedTask; } - - /// - /// Returns true when targets an older .NET than - /// — including non-modern monikers (.NET Framework, - /// .NET Standard, out-of-support netcoreapp*) which never satisfy the minimum. - /// - /// A single target framework moniker, e.g. net8.0. - private static bool IsOlderThanMinimumSupported(string moniker) - { - Match match = modernMonikerPattern.Match(moniker); - if (!match.Success) - { - return true; - } - - int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); - return major < MinimumSupportedMajor; - } } From 9d0b6427d4e877c308db423f0b2ca09a5371e600 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 12:56:49 +0200 Subject: [PATCH 9/9] Reorder ResolveFalloutVersionStep by invocation order and tag NuGet requests Reorder the private methods to match the order they're invoked from ExecuteAsync (assembly, then major, then NuGet), and add a User-Agent header to the NuGet flat-container HttpClient so requests aren't sent as anonymous script traffic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Steps/ResolveFalloutVersionStep.cs | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs index 10ee3e44b..51d5c2c3d 100644 --- a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs +++ b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs @@ -43,9 +43,9 @@ internal sealed class ResolveFalloutVersionStep : IMigrationStep /// 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 Uri flatContainerIndex = new($"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json"); - private static readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(3) }; + private static readonly HttpClient httpClient = CreateHttpClient(); /// public async Task ExecuteAsync(MigrationContext context, Summary summary) @@ -70,6 +70,31 @@ public async Task ExecuteAsync(MigrationContext context, Summary summary) } } + /// + /// Reads the tool assembly's and strips the + /// build-metadata suffix, falling back to when none is present. + /// + /// The Fallout version to pin into rewritten package references. + private static string ResolveFromAssembly() + { + string informational = typeof(ResolveFalloutVersionStep).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (string.IsNullOrEmpty(informational)) + { + return Fallback; + } + + int plusIndex = informational.IndexOf('+'); + if (plusIndex == -1) + { + return Fallback; + } + + return informational[..plusIndex]; + } + /// /// Extracts the major version segment (e.g. the calendar year) from a version string, ignoring /// any prerelease or build-metadata suffix. @@ -95,7 +120,7 @@ private static async Task ResolveFromNuGetAsync(int major) { try { - using HttpResponseMessage response = await httpClient.GetAsync(FlatContainerIndex); + using HttpResponseMessage response = await httpClient.GetAsync(flatContainerIndex); if (!response.IsSuccessStatusCode) { return null; @@ -139,27 +164,16 @@ private static async Task ResolveFromNuGetAsync(int major) } /// - /// Reads the tool assembly's and strips the - /// build-metadata suffix, falling back to when none is present. + /// 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. /// - /// The Fallout version to pin into rewritten package references. - private static string ResolveFromAssembly() + /// A configured . + private static HttpClient CreateHttpClient() { - string informational = typeof(ResolveFalloutVersionStep).Assembly - .GetCustomAttribute() - ?.InformationalVersion; - - if (string.IsNullOrEmpty(informational)) - { - return Fallback; - } + HttpClient client = new() { Timeout = TimeSpan.FromSeconds(3) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("fallout-migrate"); - int plusIndex = informational.IndexOf('+'); - if (plusIndex == -1) - { - return Fallback; - } - - return informational[..plusIndex]; + return client; } }