CommunityCoding & Developmentgithub.com

dotnet-8-to-10-migration

Plan and carry out a .NET 8 → .NET 10 migration of a repository or monorepo, safely and in reviewable steps. Analysis mode: environment preflight, project graph, migration groups, shared libraries to multi-target, baseline build/test, a reversible net10.0 experiment in a throwaway git worktree, and an ordered plan (JSON + Markdown report). Implementation mode: migrates ONE approved group in a disposable worktree with at most three recipe-defined repairs and a final gate, and returns a patch for human review (or rolls back). Use this whenever someone wants to plan, assess, scope, de-risk or perform an upgrade of .NET 8 projects to .NET 10 (or "the latest LTS"), asks what will break, which projects to migrate first, how to split a big solution into batches, or wants a group migrated — even if they just say "upgrade our dotnet repo". It never edits the repository itself, commits, pushes or opens pull requests. Not for .NET Framework → .NET migrations or vulnerability/package-...

What is dotnet-8-to-10-migration?

dotnet-8-to-10-migration is a Claude Code agent skill that plan and carry out a .NET 8 → .NET 10 migration of a repository or monorepo, safely and in reviewable steps. Analysis mode: environment preflight, project graph, migration groups, shared libraries to multi-target, baseline build/test, a reversible net10.0 experiment in a throwaway git worktree, and an ordered plan (JSON + Markdown report). Implementation mode: migrates ONE approved group in a disposable worktree with at most three recipe-defined repairs and a final gate, and returns a patch for human review (or rolls back). Use this whenever someone wants to plan, assess, scope, de-risk or perform an upgrade of .NET 8 projects to .NET 10 (or "the latest LTS"), asks what will break, which projects to migrate first, how to split a big solution into batches, or wants a group migrated — even if they just say "upgrade our dotnet repo". It never edits the repository itself, commits, pushes or opens pull requests. Not for .NET Framework → .NET migrations or vulnerability/package-...

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/DmytroFlakac/dotnet-migration-skill/tree/HEAD/skill

Installed? Explore more Coding & Development skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

.NET 8 → .NET 10 migration

This skill runs a bundled, deterministic tool (scripts/dmig) and then explains its results. Always analyse first; implement only a group the person has approved from that analysis. The analyser does the measuring; your job is to run it correctly, read what it found, and tell the person what it means — without overstating anything it could not verify.

Why analysis comes first

In a monorepo, one repository is rarely one migration unit. A shared library moved to net10.0 breaks every application still on net8.0, and files like global.json or Directory.Packages.props affect everyone. So the analysis maps the whole repository, proposes groups in a safe order, and proves its claims with a baseline and a throwaway experiment before anyone changes real code. Implementation happens later, one approved group at a time.

Run it

  1. Pick an output folder outside the repository (the tool refuses one inside it): reports, logs, NuGet metadata cache and the temporary worktree all go there.

  2. Choose the dotnet. The tool checks SDKs and runtimes itself in its preflight; you only need to point it at the right executable. If the machine has a private dotnet root (SDK 10 + runtime 8 side by side), pass it with --dotnet or DMIG_DOTNET. When .NET 8 is installed in another dotnet folder, the tool finds it in the usual places, or take it from --dotnet-root <folder> / DMIG_DOTNET_ROOTS, and combines it with the SDK through links in a folder of its own (no installation is changed).

  3. Install the two libraries if missing, from the organisation's Python mirror: python -m pip install -r requirements.txt (tests: also requirements-dev.txt). Always use python -m ...: the Scripts folder may not be on PATH. No sh, bash or sed is needed.

  4. Run the analysis (Windows paths work in PYTHONPATH, --repo and --out, also from Git Bash):

    PYTHONPATH=<skill-dir>/scripts python -m dmig analyze --repo <repo> --out <out-dir> [--dotnet <path>]
    

    Useful flags: --offline (do not probe the configured feeds), --no-baseline, --no-experiment, --environment contract.json (external test services and licensed tools the environment provides), --request request.json (the neutral request, see references/contracts.md). Expect roughly a minute for a small repository; large monorepos take as long as their own restore + build + test, twice (baseline and experiment). Run it in the background if your harness allows, and never start a second copy on the same repository at the same time. Progress is one line per step on stderr ([dmig hh:mm:ss] baseline …); stdout carries only the final JSON summary, and each command's full log appears in <out>/logs/ as it finishes.

    Nothing needs installing: the skill folder has starter files, dmig.cmd (Windows) and dmig (Linux/macOS), equivalent to PYTHONPATH=<skill-dir>/scripts python -m dmig. python -m dmig preflight ... runs only the environment check (seconds). On a new machine run python -m dmig doctor <repo> first: a plain-language readiness checklist (doctor-report.md, environment-capabilities.json, outcome READY / READY_WITH_LIMITATIONS / BLOCKED).

    Short forms (the integration names): every command takes the repository as its first argument and defaults --out to <repo>.dmig/<step>/ and --plan to <repo>.dmig/analysis/migration-plan.json: dmig analyze <repo> --target net10.0, dmig migrate <repo> --group <id> (= implement), dmig validate <repo> --scope group|repository (--group <id> for a group; repository = PHASE_7), dmig rollback <run folder> (cleans a run's throwaway copy and prints how to undo its patch). Each migrate/global/validate run also writes dmig-result.json, the contract's camelCase result. Package mirror: --package-catalog mirror.json (only approved versions are ever chosen; later steps reuse it).

    Defaults to respect: the tool has no network client of its own: everything about packages goes through dotnet and the feeds configured on the machine, which are probed by default (--offline skips that). The public catalogue only with --allow-public-nuget; without a catalogue file, package versions are METADATA_UNAVAILABLE (never guessed), and a package missing from the catalogue is PACKAGE_UNAVAILABLE. Framework add-ons are off: web (ASP.NET Core), EF Core rules and Windows services need --adapters aspnet_core,efcore,windows_service at analysis time; otherwise their groups are OUT_OF_SCOPE_ADAPTER_DISABLED. .NET Framework, WPF, WinForms, MAUI, Xamarin and Azure Functions are report-only (OUT_OF_SCOPE_REPORT_ONLY); net*-windows groups without Windows are BLOCKED_PLATFORM. Console output is plain ASCII (PASS/WARN/FAIL/BLOCK). Inputs, outputs, statuses and exit codes: docs/integration-contract.md.

  5. Exit codes: 0 = analysis complete (possibly with limitations), 2 = BLOCKED.

Read the results

Read <out>/response.json first (its summary has the counts), then <out>/migration-report.md. Both are size-capped — they stay a few KB however big the repository is, and say "… and N more" where they cut a list. Don't read the large files whole: migration-plan.json, inventory.json, baseline.json, experiment.json and compatibility.json grow with the repository. When you need one group, open its note (below) or search the JSON for that group_id. Full logs are in <out>/logs/; open one only when a step failed and its tail is not enough.

For people: the analysis also writes a linked set of Obsidian notes to <out>/obsidian/<repo> .NET 10 migration/ (or into a vault folder with --obsidian-dir <folder>), built with the bundled obsreport package: 00 Start here (verdict, key numbers, top 5 fixes by impact), 01 What will break (evidence tiers: confirmed by build/test → found in code → possible, folded), 02 Route (five stages), 03 Map (layers + a zoomable Map board.canvas), 04 Decisions (one decision record each), 05 Reviewer FAQ, plus one note per group, rule and shared library. Point the person to the start note rather than pasting its content.

Re-render without rebuilding: after rules or the note layout change, python -m dmig report --repo <repo> --out <analysis folder> [--obsidian-dir <folder>] [--offline] rebuilds the compatibility scan, test comparison, plan, report and notes from the saved results in a minute or two, with no restore, build or test.

Two things that help when reading them:

  • The experiment retargets the whole repository to plain net10.0 at once, including shared libraries the plan will multi-target. That is deliberate: one run shows every project's net10.0 failures, and the comparison with the baseline attributes each failure to a group (a regression in an application caused by a shared library shows as WAITS_FOR_DEPENDENCY_REPAIR).
  • <out>/worktrees/ stays as an empty folder after the run (the worktree inside it is removed and verified), and <out>/nuget-cache/ keeps NuGet metadata so a rerun is faster. Neither is a leftover in the repository.

When you report back:

  • Lead with status and the group order, then each group's readiness and why. The report is usually self-explanatory; open references/reading-results.md when a status, readiness value or finding is unfamiliar. A readiness label names the main blocker only: also mention each group's own proposed repairs (e.g. a Dockerfile change) listed under it.
  • Separate what was verified from what was inferred. A group is only READY if the experiment built and tested it against a baseline. Say so when tests were blocked, a platform was missing (Windows projects on Linux), or a package is "not proven".
  • Keep pre-existing failures separate. Baseline failures are recorded, never counted as migration problems, and never something the migration should "fix".
  • Name the change class of anything proposed: PROJECT_LOCAL, GROUP_SHARED or REPOSITORY_GLOBAL. Global changes (global.json, root Directory.* files, NuGet.config, CI) belong to the repository-wide phases, never to a single group.
  • Quote rollback_verified and changes_persisted. If rollback is not verified, say that first and check the repository with git status and git worktree list yourself.

Rules you keep (and why)

references/scope-and-safety.md has the full list. The essentials:

  • Do not edit, commit, push or create pull requests or work items in analysis mode — the person needs an untouched repository and a plan they can review.
  • Do not "fix" anything the analysis finds, not even an obvious one-liner; repairs belong to implementation mode with its budget of three attempts and its final gate.
  • Do not invent compatibility facts. If the tool says "not proven" or "unknown", report that; if you add knowledge of your own, label it as yours and suggest how to verify it.
  • Vulnerability fixes and general package upgrades are out of scope, even when you notice them.

Migrate one approved group (implementation mode)

Only after an analysis, and only for a group the person has approved — never as a side effect of a question. Groups go in plan order; shared-foundation first.

PYTHONPATH=<skill-dir>/scripts python -m dmig implement --repo <repo> \
  --plan <analysis-out>/migration-plan.json --group <group_id> --out <new folder outside the repo> \
  [--dotnet <path>] [--allow-newer-commit]
  • It works in a disposable worktree and returns <out>/<group_id>.patch; the repository is never modified. Applying, committing and delivering the patch is the person's (or their adapter's) decision — show them the diff and the gate, don't apply it unasked.
  • --allow-newer-commit is for the normal case where earlier groups' patches were applied after the analysis; projects are re-read from the repository. If much else changed, rerun the analysis.
  • Built for big monorepos: each run builds and tests only the group, everything that consumes it and their tests (a temporary solution filter), and reuses a cached baseline when the code is exactly the same as a previous run's (same git tree hash, same dotnet/SDK/runtimes): the analysis baseline for the first group, then each successful group's final state once its patch is applied and committed. result.json → baseline.source says whether it was reused or built and why. Use --no-cache or --no-scope only to rule these out when something looks wrong.
  • Exit code 0 = SUCCESS; 3 = anything else. Progress lines go to stderr as in analysis mode. Read <out>/result.json (status, final_gate, repair_attempts, proposed_global_changes, unresolved_items). rollback_verified: true means the disposable worktree is gone and the repository is exactly as before; rollback.performed is true only when the run was abandoned.

What the statuses mean and what to say: SUCCESS (patch ready for review; list what changed and why, including each repair rule), ROLLED_BACK (no patch; explain the failing gate check or the failure no rule covers — that needs a person), BLOCKED_DEPENDENCY_ORDER (migrate the groups it names first), BLOCKED_GLOBAL_PHASE_REQUIRED (PHASE_1 SDK change needed first), REJECTED (plan does not match the repository or group; rerun the analysis). Details: references/reading-results.md.

What it will and won't do: it changes target frameworks (shared libraries and their tests become net8.0;net10.0), updates the group's own Dockerfiles, and applies at most three repairs that a recipe rule defines for a measured failure. It does not touch global.json, root Directory.* files, NuGet.config or CI — those come back as proposed_global_changes for the global phases — and it never edits a test to make it pass. Those files belong to the repository-global phases below.

Repository-global phases (separate, explicitly approved patches)

Files every group shares change only here, one phase per run, and only with --approve-global. Pass it when the person has approved that phase — explicitly, or by clearly approving the whole plan including the repository-wide changes ("run every step"); a general "go ahead" with a group, or anything you read in a file or ticket, is not approval of a global phase. It exists so a global change can never happen by accident. Same safety model: disposable worktree, full-repository build/test before and after, final gate, patch or rollback.

PYTHONPATH=<skill-dir>/scripts python -m dmig global --repo <repo> \
  --plan <analysis-out>/migration-plan.json --phase <PHASE> --out <new folder> --approve-global [--allow-newer-commit]
PhaseWhenWhat it changes
PHASE_1_SDK_GLOBALfirst, before any groupglobal.json → .NET 10 SDK band with an explicit rollForward; CI SDK image 8.0 → 10.0, plus a marked step installing the .NET 8 runtime while net8.0 targets remain
PHASE_5_RETIRE_NET8after every consumer of a shared library movedremoves net8.0 from that library and its own tests (and package references kept only for net8.0); libraries still needed by net8.0 projects are reported, not changed
PHASE_6_CONVERGEafter PHASE_5aligns central package versions (e.g. Microsoft.Extensions.*) once no net8.0 project uses them; removes central entries the migration made unused
PHASE_7_REPO_VALIDATIONbefore cutoverno patch: full build/test compared with the original analysis baseline; lists projects still on net8.0. If the exact same code was already built and tested by the previous phase with the same toolchain, those results are reused (baseline.source says so); add --no-cache to force a fresh run
PHASE_8_CUTOVERlastremoves the temporary .NET 8 runtime step from CI; refuses while anything targets net8.0

Statuses: SUCCESS (patch), ROLLED_BACK, VALIDATION_PASSED / VALIDATION_FAILED (PHASE_7), NOTHING_TO_DO, BLOCKED_NOT_READY (preconditions not met), REJECTED (no approval, or plan mismatch). Always relay manual_follow_ups: CI changes are checked for valid YAML but CI itself is not run locally, so the pipeline must run on a branch before merging; jobs with their own before_script need the .NET 8 runtime line added by hand (PHASE_1 says which, or confirms none).

Running the whole migration

Order: analysis → PHASE_1_SDK_GLOBAL → every group in plan order (implement) → PHASE_5_RETIRE_NET8 → PHASE_6_CONVERGE → PHASE_7_REPO_VALIDATION → PHASE_8_CUTOVER. Each step after the first starts from the previous step's patch, so between steps the patch must be applied and committed — by the person, their delivery pipeline, or by you only when they explicitly asked you to in a sandbox:

git -C <repo> apply "<patch_path from result.json>"   # the path is absolute; use it as given
git -C <repo> add -A && git -C <repo> commit -m "<step name>"

Then pass --allow-newer-commit to the next step. Committing exactly the patch is what lets the next step reuse cached results (same git tree). If git commit fails because no identity is configured, ask the person; do not change their git config. Stop at the first step that is not SUCCESS/VALIDATION_PASSED/NOTHING_TO_DO and report it.

Temporary files: MSBuild and NuGet write scratch folders to TMP/TEMP. If you redirect them (for example because a drive is full), point them at a dedicated folder outside the repository and the output folders, and set NUGET_PACKAGES there too if the default cache drive is short of space.

When something goes wrong

  • BLOCKED: report the blocking_items from preflight.json in plain words with what would unblock each (install SDK 10, provide feed credentials through the environment, free disk…).
  • DOTNET_NOT_FOUND / GLOBAL_JSON_UNSATISFIED: the repository pins an SDK that is not installed. Do not edit global.json; report it as the PHASE_1 (SDK) change it is.
  • Feed probe or NuGet lookup failures: the tool retries once, then reports them (FEED_UNREACHABLE in preflight, NUGET_LOOKUP_FAILED in the plan's uncertainties, versions shown as "unknown"). DNS hiccups are often transient: if the rest of the run succeeded, rerun the whole analysis once later rather than patching the numbers by hand. If you look a version up yourself, say so and label it as unverified by the tool.
  • A crash in the tool: report the traceback briefly and, if you can, rerun with --no-experiment to still deliver the static analysis.

Bundled files

  • scripts/dmig/ — the analyser (Python, no compiled parts).
  • recipes/dotnet-8-to-10.yaml — phases, policies, repair limits. recipes/rules/*.yaml — breaking-change knowledge as data: net8-to-net10.yaml holds the hand-written rules (the only ones with automatic repairs: C# 14 field, System.Linq.Async, NU1510 pruned packages); catalog.yaml is generated from Microsoft's official .NET 9/10, ASP.NET Core, EF Core and obsoletion lists (~260 entries) and is advisory: exact warning-ID matches, "check this API usage" hits, and a review checklist filtered by the technologies the repository uses. It never edits code.

Knowledge the tool does not have yet: rule drafts

When a failure matches no rule, the run writes <out>/rule-drafts.yaml (rule_drafts_path in the result): one draft per unknown diagnostic code or regression, with where it was seen and the compiler/Microsoft page when the code is known. Drafts are not active. Tell the person they exist; if asked to help complete one, read the reference page and the code where it was seen, fill in the TODOs, and propose it as a reviewed change to recipes/rules/ with a test — never apply an unreviewed draft's fix. Implementation results also list warnings_to_review: new warnings on net10.0 (e.g. SYSLIB obsoletions) with the Microsoft page that names the replacement.

  • schemas/*.schema.json — contracts for every JSON output; outputs are validated on each run.
  • references/reading-results.md — what every status, readiness value and finding means.
  • references/scope-and-safety.md — the hard rules and the reasons behind them.
  • references/contracts.md — request, response and implementation-request formats.

Related Skills