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 @@
+
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