diff --git a/AGENTS.md b/AGENTS.md index 06c65cd7..10bbb356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,19 +123,24 @@ Checks worth running before triggering, in this order: 4. `_config.yml` still sets `timezone: Europe/London`. 5. A local build without `--future` includes the post. -A script wraps all of the above, triggers the build, waits for it to finish, and -polls the live URL until it returns 200. It lives outside this repo: +A script in this repo wraps all of the above. It runs every check in order, +triggers the build, waits for it to finish, and polls the live URL until it +returns 200: ```text -~/Dropbox/Projects/foremost/trello/weekly-current-todo-list/seedfolder-blog-publish-future-post.sh +script/publish-post ``` +It works out the repo root from its own location, so it runs from any clone on +any machine and from any working directory. `script` is in the `exclude:` list +in `_config.yml`, so it is never served from the site. + The date and slug are the two halves of the post filename. Pass `--yes` whenever an agent or any other non-interactive shell runs it, or it blocks forever on the confirm prompt: ```bash -./seedfolder-blog-publish-future-post.sh \ +script/publish-post \ 2026-08-06 why-my-ga4-explorations-only-went-back-two-months --yes ``` @@ -306,6 +311,8 @@ _layouts/ Jekyll layouts _includes/ Jekyll includes public/css/ SCSS stylesheets images/ Blog post cover and social images +script/ Maintenance scripts, excluded from the built site +script/publish-post Publishes a future-dated post, see above README.md Human maintainer workflow AGENTS.md AI agent workflow, the single source CLAUDE.md Pointer to AGENTS.md diff --git a/_posts/2026-08-11-dotnet-tools-roll-forward-major-version.md b/_posts/2026-08-11-dotnet-tools-roll-forward-major-version.md new file mode 100644 index 00000000..56736aa0 --- /dev/null +++ b/_posts/2026-08-11-dotnet-tools-roll-forward-major-version.md @@ -0,0 +1,292 @@ +--- +layout: post +title: .NET Tools Stop Working When You Upgrade .NET +description: A .NET global tool targeting one major version refuses to run when only a newer major is installed. Roll-forward does not cross major boundaries by default. Here is how I reproduced it in Docker and fixed it with one MSBuild property. +summary: While planning to drop .NET 8 from a global tool I hit a question I could not answer from memory, so I tested it. A net8.0 tool on a machine with only the .NET 10 runtime does not start. One property in the csproj fixes it, and global.json, the thing I assumed would help, turns out to be irrelevant. +cover_image: /images/dotnet-rollforward-cover.svg +image: /images/dotnet-rollforward-cover.png +tags: +- dotnet +- dotnet-10 +- csharp +- dotnet-global-tool +- nuget +- docker +- testing +--- + +**Overview** โ˜€ + +I was doing some housekeeping on `seedfolder`, a small .NET global tool I +maintain that creates a project folder and fills it with sensible dotfiles. It +targets `net8.0`, `net9.0` and `net10.0`. + +.NET 8 and .NET 9 both reach end of support on 10 November 2026, so the plan was +simple. Wait for November, drop both, ship a .NET 10 only version. + +Then I asked myself a question I could not answer with any confidence, and the +answer turned out to be the opposite of what I assumed. + +**The Question** ๐Ÿ” + +Here is the scenario. A user installs the tool today while it targets .NET 10. +Some months later .NET 11 lands. Homebrew upgrades them, or Windows Update does, +and the .NET 10 runtime is no longer on their machine. + +Does the tool still run? + +My instinct said "add a `global.json` with roll-forward and it will be fine". +That instinct was wrong twice over. Wrong about `global.json`, and wrong about +which half of the problem it applies to. + +**Why global.json Is The Wrong Tool** โš™๏ธ + +`global.json` selects the **SDK** used when building a repository. It has +nothing to do with people who installed your tool from NuGet. + +Worse, adding one cannot help even for the build. From the +[matching rules](https://learn.microsoft.com/dotnet/core/tools/global-json#matching-rules): + +> If no global.json file is found, or global.json doesn't specify an SDK version +> and doesn't specify an allowPrerelease value, the highest installed SDK version +> is used (equivalent to setting `rollForward` to `latestMajor`). + +Having no `global.json` is already the most permissive setting available. Adding +one can only narrow SDK selection, never widen it. + +And there is a trap. If you add a `global.json` with a `version` and forget +`rollForward`, the default policy is `patch`. That file will break your build the +moment the pinned SDK disappears, which is exactly the failure people add a +`global.json` hoping to prevent. + +So `global.json` earns its place when you want a reproducible floor for CI. It is +not a roll-forward mechanism. + +**The Half That Actually Matters** ๐ŸŽฏ + +.NET tools are framework-dependent applications. When you run one, the host looks +for the runtime the tool was built against. From the +[troubleshooting docs](https://learn.microsoft.com/dotnet/core/tools/troubleshoot-usage-issues#installed-net-tool-fails-to-run): + +> Roll-forward won't occur by default in two common scenarios: ... Only higher +> major versions of the runtime are available. Roll-forward doesn't cross major +> version boundaries. + +That is the whole problem in one sentence. Patch and minor roll-forward happen +automatically. Major does not. + +**Reproducing It Today** ๐Ÿงช + +I did not want to take the docs on faith, and I did not want to wait until .NET +11 ships to find out. So I built a proxy. + +A tool targeting `net8.0`, running on a machine that has only the .NET 10 +runtime, is structurally identical to a `net10.0` tool on a future .NET 11 box. +Missing major, higher major available. Both exist today, so it is testable now. + +Docker made this easy. The official SDK 10 image carries only the .NET 10 +runtime: + +```bash +docker run --rm mcr.microsoft.com/dotnet/sdk:10.0 dotnet --list-runtimes +``` + +``` +Microsoft.AspNetCore.App 10.0.10 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App] +Microsoft.NETCore.App 10.0.10 [/usr/share/dotnet/shared/Microsoft.NETCore.App] +``` + +No .NET 8 anywhere. Exactly the machine I needed. + +**The Test Script** ๐Ÿ“œ + +I copied the repo with `git archive` so the working tree was never touched, then +ran three cases in the container. Single-target `net8.0`, pack, install as a +global tool from a local folder, run it. + +```bash +#!/bin/bash +set -u +cd /work +export PATH="$PATH:/root/.dotnet/tools" +CSPROJ=src/solrevdev.seedfolder.csproj + +dotnet --list-runtimes + +# Target the missing major. Use a version that cannot exist on nuget.org so the +# package can only resolve from the local folder. +sed -i 's|net8.0;net9.0;net10.0|net8.0|' "$CSPROJ" +sed -i 's|1.6.0|99.0.0|' "$CSPROJ" + +run_case() { + echo "---------- $1 ----------" + rm -rf src/nupkg src/bin src/obj + dotnet pack "$CSPROJ" -c Release -o /work/nupkg >/dev/null 2>&1 + + grep -i "rollForward" src/bin/Release/net8.0/*.runtimeconfig.json \ + || echo " (no rollForward key present)" + + dotnet tool uninstall -g solrevdev.seedfolder >/dev/null 2>&1 + dotnet tool install -g solrevdev.seedfolder --version 99.0.0 \ + --add-source /work/nupkg ${EXTRA_INSTALL_FLAGS:-} >/dev/null 2>&1 + + if out=$(seedfolder --version 2>&1); then + echo " SUCCESS -> $out" + else + echo " FAILED" + echo "$out" | sed 's/^/ | /' + fi +} + +EXTRA_INSTALL_FLAGS="" +run_case "A: no RollForward" + +EXTRA_INSTALL_FLAGS="--allow-roll-forward" +run_case "C: same tool, user opts in at install time" + +EXTRA_INSTALL_FLAGS="" +sed -i 's|latest|latest\n Major|' "$CSPROJ" +run_case "B: RollForward=Major baked in" +``` + +Run it against a checkout: + +```bash +docker run --rm -v "$PWD:/work" -w /work mcr.microsoft.com/dotnet/sdk:10.0 bash /work/run-test.sh +``` + +**The Results** ๐Ÿ“Š + +| Case | runtimeconfig.json | Result | +| --- | --- | --- | +| A. No `RollForward` | no `rollForward` key | Failed, exit 150 | +| B. `Major` | `"rollForward": "Major"` | Worked | +| C. Case A installed with `--allow-roll-forward` | no `rollForward` key | Worked | + +Case A is the one that matters. This is what a user sees: + +``` +You must install or update .NET to run this application. + +App: /root/.dotnet/tools/seedfolder +Architecture: arm64 +Framework: 'Microsoft.NETCore.App', version '8.0.0' (arm64) +.NET location: /usr/share/dotnet + +The following frameworks were found: + 10.0.10 at [/usr/share/dotnet/shared/Microsoft.NETCore.App] + +Learn more: +https://aka.ms/dotnet/app-launch-failed +``` + +A perfectly good runtime is sitting right there, listed in the error message, and +the host refuses to use it. Not because it would not work, but because nobody +told it that it was allowed to. + +**The Fix Is One Property** ๐Ÿ”ง + +```xml + + Major + +``` + +That writes the policy into the tool's `runtimeconfig.json` at pack time: + +```json +{ + "runtimeOptions": { + "tfm": "net8.0", + "rollForward": "Major", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "8.0.0" + } + } +} +``` + +With that in place the same tool started immediately on the .NET 10 only box. + +I did not stop at `--version`, because printing a version string proves very +little. I ran the tool properly and had it seed a real project: + +```bash +seedfolder --quiet -t node my-app +``` + +``` +Files created: + .editorconfig + .gitattributes + .gitignore + .prettierignore + .prettierrc + index.js + package.json +``` + +Every file written, on a runtime two major versions newer than the one the tool +was built for. Worth noting that it crossed **two** majors, 8 to 10, without +complaint. + +**The Escape Hatch Users Have** ๐Ÿชœ + +Case C is the interesting one for anyone stuck with a tool whose author has not +done this. Since the .NET 9 SDK there is an install-time flag: + +```bash +dotnet tool install -g some.tool --allow-roll-forward +``` + +It sets roll-forward mode to `Major` for that tool without the author changing +anything. It works, and I confirmed it works. But it depends on the user knowing +the flag exists, at the exact moment their tool has stopped working and they are +already annoyed. That is not a plan, it is a rescue. + +**What I Am Changing** ๐Ÿ›  + +Two things, and only one of them is urgent. + +The `RollForward` property goes in now. It is one line, it is backwards +compatible, and it helps immediately. Even while the tool multi-targets .NET 8, 9 +and 10, a user on .NET 11 with none of those runtimes installed hits Case A +today. + +The framework drop waits for November. When .NET 8 and 9 go out of support the +tool becomes .NET 10 only, and at that point `RollForward` stops being a nice +safety net and becomes the thing keeping the tool alive between LTS releases. + +There is a real tension here that took me a moment to see. Dropping old target +frameworks is normally framed as a maintenance win. For a global tool it is also +a compatibility cliff, in both directions. Drop .NET 8 too early and you lock out +users who have not upgraded. Ship .NET 10 only without roll-forward and you lock +out the users who have. + +**What I Took Away** ๐Ÿ’ก + +Three things worth keeping. + +**Check which layer a setting applies to.** I nearly reached for `global.json` +for a runtime problem. It is a build-time SDK selector. The names being similar, +`rollForward` in both places, made the confusion easy. + +**The permissive default is often the absence of the file.** No `global.json` +already behaves like `latestMajor`. Adding configuration to get a behaviour you +already have is a good way to lose it. + +**Build the proxy rather than waiting for the real thing.** I could not test .NET +11 because it does not exist yet. Testing .NET 8 against .NET 10 answered exactly +the same question, today, in about twenty minutes and one container. + +**What Is Next** ๐Ÿ”ฎ + +The property goes in with the next release. In November the target frameworks +collapse to `net10.0` and the tool gets a version bump to match. + +If you maintain a .NET global tool, this is worth thirty seconds of your time. +Open your csproj and look for `RollForward`. If it is not there, your tool will +stop working for somebody the day their machine moves on without you. + +Success! ๐ŸŽ‰ diff --git a/images/dotnet-rollforward-cover.png b/images/dotnet-rollforward-cover.png new file mode 100644 index 00000000..572a98a6 Binary files /dev/null and b/images/dotnet-rollforward-cover.png differ diff --git a/images/dotnet-rollforward-cover.svg b/images/dotnet-rollforward-cover.svg new file mode 100644 index 00000000..80523e11 --- /dev/null +++ b/images/dotnet-rollforward-cover.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + mcr.microsoft.com/dotnet/sdk:10.0 + + $ seedfolder --version + + You must install or update .NET to run this application. + Framework: 'Microsoft.NETCore.App', version '8.0.0' + The following frameworks were found: + 10.0.10 at [/usr/share/dotnet/shared/...] + + fix: <RollForward>Major</RollForward> + + .NET tools stop working when you upgrade .NET + Roll-forward does not cross major versions. One property fixes it. + diff --git a/script/publish-post b/script/publish-post new file mode 100755 index 00000000..4eaafb04 --- /dev/null +++ b/script/publish-post @@ -0,0 +1,232 @@ +#!/bin/bash +# +# Publish a future-dated Jekyll post on solrevdev.com. +# +# WHY THIS EXISTS +# +# The blog is a legacy GitHub Pages site: no Actions workflow, _site is +# gitignored, and GitHub builds Jekyll server-side from master on push. +# +# _config.yml sets no `future` key, so Jekyll defaults to `future: false`. +# A post dated in the future is silently excluded from every build until its +# date arrives. Merging it does not publish it. Nothing rebuilds on its own, +# so the post sits invisible until something triggers a fresh build. +# +# This script triggers that build, on or after the date, and checks it worked. +# +# USAGE +# +# script/publish-post [--yes] [--skip-local] +# +# The date and slug come straight off the filename. For +# _posts/2026-08-06-why-my-ga4-explorations-only-went-back-two-months.md +# the date is 2026-08-06 and the slug is everything after it, minus the .md. +# +# WORKED EXAMPLE (this one really ran, on 6 Aug 2026) +# +# script/publish-post \ +# 2026-08-06 why-my-ga4-explorations-only-went-back-two-months --yes +# +# ...published https://solrevdev.com/2026/08/06/why-my-ga4-explorations-only-went-back-two-months.html +# +# FLAGS +# +# --yes, -y Skip the "trigger a rebuild?" prompt. REQUIRED when an agent +# or any non-interactive shell runs this, otherwise the script +# blocks forever on `read` waiting for a keypress nobody sends. +# --skip-local Skip the local Jekyll build check. Use when you have already +# built locally, or when the bundle is not installed. You lose +# the early warning and find out from the 404 instead. +# +# RE-RUNNING IS SAFE. It just asks GitHub for another Pages build. Run it again +# if the CDN was still catching up and the final check timed out. +# +# IF IT SAYS "built" BUT THE URL 404s +# +# Check `timezone:` in _config.yml. Without it Jekyll uses the build machine's +# timezone, and GitHub Pages builds in UTC. A post dated with no time sits at +# 00:00, so during BST it stays "in the future" for the first hour of the day +# and future: false drops it. The local check below passes because your Mac is +# on BST, so you get a green tick and a 404. The repo now pins +# `timezone: Europe/London`, which keeps the local check honest. +# +set -euo pipefail + +REPO="solrevdev/solrevdev.github.io" +# Derived from this script's own location, so it works in any clone on any +# machine. The script lives in script/, so the repo root is one level up. +REPO_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SITE="https://solrevdev.com" + +POST_DATE="${1:-}" +POST_SLUG="${2:-}" +shift 2 2>/dev/null || true + +ASSUME_YES=false +SKIP_LOCAL=false +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=true ;; + --skip-local) SKIP_LOCAL=true ;; + *) echo "Unknown flag: $arg" >&2; exit 1 ;; + esac +done + +if [[ -z "$POST_DATE" || -z "$POST_SLUG" ]]; then + echo "Usage: $0 [--yes] [--skip-local]" >&2 + echo >&2 + echo "Example (a real one that published successfully):" >&2 + echo " $0 2026-08-06 why-my-ga4-explorations-only-went-back-two-months --yes" >&2 + echo >&2 + echo "Date and slug come from the filename:" >&2 + echo " _posts/2026-08-06-why-my-ga4-explorations-only-went-back-two-months.md" >&2 + echo " |________| |___________________________________________|" >&2 + echo " date slug" >&2 + echo >&2 + echo " --yes skip the confirm prompt. Required for agents and any" >&2 + echo " non-interactive shell, or the script hangs on read." >&2 + echo " --skip-local skip the local Jekyll verification build." >&2 + exit 1 +fi + +if ! [[ "$POST_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "โŒ Date must be YYYY-MM-DD, got '$POST_DATE'" >&2 + exit 1 +fi + +YEAR="${POST_DATE:0:4}"; MONTH="${POST_DATE:5:2}"; DAY="${POST_DATE:8:2}" +POST_FILE="_posts/${POST_DATE}-${POST_SLUG}.md" +POST_URL="${SITE}/${YEAR}/${MONTH}/${DAY}/${POST_SLUG}.html" +TODAY="$(date +%Y-%m-%d)" + +echo "==================================================" +echo " Post file : $POST_FILE" +echo " Post URL : $POST_URL" +echo " Post date : $POST_DATE" +echo " Today : $TODAY" +echo "==================================================" +echo + +# --------------------------------------------------------------- +# 1. Refuse to run early. This is the whole point of the script. +# --------------------------------------------------------------- +if [[ "$TODAY" < "$POST_DATE" ]]; then + echo "โŒ Too early. The post is dated $POST_DATE and today is $TODAY." + echo " Jekyll will exclude it from the build and you will get a 404." + echo " Run this on or after $POST_DATE." + exit 1 +fi +echo "โœ… Date has arrived. Jekyll will include the post." +echo + +# --------------------------------------------------------------- +# 2. Sanity check the repo and the file. +# --------------------------------------------------------------- +cd "$REPO_PATH" + +if [[ ! -f "$POST_FILE" ]]; then + echo "โŒ $POST_FILE does not exist in $REPO_PATH" >&2 + exit 1 +fi + +if grep -qE "^published:[[:space:]]*false" "$POST_FILE"; then + echo "โŒ $POST_FILE has 'published: false'." + echo " The date alone will not publish it. Remove the flag or set it true." + exit 1 +fi +echo "โœ… Post file exists and is not held back by 'published: false'." + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$BRANCH" != "master" ]]; then + echo "โš ๏ธ On branch '$BRANCH', but Pages builds from 'master'." + echo " Merge first, or the rebuild will not include your post." +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "โš ๏ธ Working tree has uncommitted changes. GitHub builds what is pushed." +fi + +if ! git diff --quiet HEAD origin/master -- "$POST_FILE" 2>/dev/null; then + echo "โš ๏ธ $POST_FILE differs from origin/master. Push before rebuilding." +fi +echo + +# --------------------------------------------------------------- +# 3. Prove locally that the post now builds WITHOUT --future. +# If it does not appear here, it will not appear on GitHub either. +# --------------------------------------------------------------- +if [[ "$SKIP_LOCAL" == false ]]; then + echo "Building locally without --future to confirm the post is picked up..." + TMP_DEST="$(mktemp -d)" + if bundle exec jekyll build --destination "$TMP_DEST" --quiet; then + if [[ -f "${TMP_DEST}/${YEAR}/${MONTH}/${DAY}/${POST_SLUG}.html" ]]; then + echo "โœ… Post builds locally without --future. GitHub will include it." + else + echo "โŒ Post did NOT appear in a local build without --future." + echo " Check the filename date, the slug, and the published flag." + rm -rf "$TMP_DEST" + exit 1 + fi + else + echo "โŒ Local Jekyll build failed. Fix that before triggering a rebuild." + rm -rf "$TMP_DEST" + exit 1 + fi + rm -rf "$TMP_DEST" + echo +fi + +# --------------------------------------------------------------- +# 4. Trigger the GitHub Pages build. +# --------------------------------------------------------------- +if [[ "$ASSUME_YES" == false ]]; then + read -r -p "Trigger a GitHub Pages rebuild of $REPO now? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } +fi + +echo "Requesting a Pages build..." +gh api -X POST "repos/${REPO}/pages/builds" --jq '.status' \ + || { echo "โŒ Failed to request the build." >&2; exit 1; } + +# --------------------------------------------------------------- +# 5. Wait for it to finish. +# --------------------------------------------------------------- +echo "Waiting for the build to complete..." +for i in $(seq 1 40); do + STATUS="$(gh api "repos/${REPO}/pages/builds/latest" --jq '.status' 2>/dev/null || echo "unknown")" + case "$STATUS" in + built) + echo "โœ… Build finished: built" + break ;; + errored) + echo "โŒ Build errored. Details:" + gh api "repos/${REPO}/pages/builds/latest" --jq '.error.message' + exit 1 ;; + *) + printf " attempt %02d: %s\n" "$i" "$STATUS" + sleep 15 ;; + esac +done + +# --------------------------------------------------------------- +# 6. Confirm the post is actually live. +# --------------------------------------------------------------- +echo +echo "Checking $POST_URL ..." +for i in $(seq 1 10); do + CODE="$(curl -s -o /dev/null -w '%{http_code}' "$POST_URL" || echo "000")" + if [[ "$CODE" == "200" ]]; then + echo "โœ… Live: $POST_URL" + echo + echo "Also worth eyeballing:" + echo " $SITE/" + echo " $SITE/archive/" + exit 0 + fi + printf " attempt %02d: HTTP %s\n" "$i" "$CODE" + sleep 20 +done + +echo "โš ๏ธ Build reported success but $POST_URL is not returning 200 yet." +echo " The CDN can lag a few minutes. Check again shortly." +exit 1