Skip to content

Select the newest installed .NET runtime for C# script steps, and upgrade dotnet-script to 2.0.1 - #2132

Draft
NickJosevski wants to merge 2 commits into
mainfrom
nj/dotnet-script-runtime-policy
Draft

Select the newest installed .NET runtime for C# script steps, and upgrade dotnet-script to 2.0.1#2132
NickJosevski wants to merge 2 commits into
mainfrom
nj/dotnet-script-runtime-policy

Conversation

@NickJosevski

Copy link
Copy Markdown
Contributor

Draft — alternative mechanism to #2095, plus the dotnet-script version bump. Two independent commits; see "Should this be one PR?" below.

Why

dotnet-script is framework-dependent on Microsoft.NETCore.App 8.0.0 and the runtimeconfig in the zip we vendor sets no rollForward, so the default Minor policy binds to an 8.x runtime and nothing else. Two live consequences, neither caused by the net10 migration:

  • a target with only a newer runtime cannot run C# script steps at all — exit 150, You must install or update .NET to run this application
  • a target with both 8.x and something newer silently stays on .NET 8

The second is why every Octopus Cloud dynamic worker runs C# scripts on .NET 8 today even with .NET 10 installed alongside. It also means that when .NET 8 goes EOL (2026-11-10) and images stop shipping the 8.x runtime, every C# script step jumps to .NET 10 at once, at a moment nobody chose.

Calamari's own TFM has no bearing on any of this — Calamari ships self-contained with no dotnet muxer, so it launches dotnet-script using the target's dotnet.

Commit 1 — rollForward: LatestMajor

Overwrites the extracted runtimeconfig.json with a copy carrying "rollForward": "LatestMajor". Verified against the built artefact with no environment variable set:

Installed on target Before After
.NET 8 SDK + .NET 10 runtime 8.0.29 10.0.10
.NET 10 SDK only exit 150 10.0.10
.NET 8 SDK only 8.0.29 8.0.29 (unchanged)

Why this instead of DOTNET_ROLL_FORWARD (#2095):

  • The host reads runtimeconfig.json on every launch path, including the Windows dotnet-script.cmd, so no invocation change is needed on either platform.
  • It cannot leak. DOTNET_ROLL_FORWARD is inherited by every process the customer's script starts — measured: a grandchild process sees DOTNET_ROLL_FORWARD=Major. A script shelling out to its own framework-dependent .NET 8 tool would make that tool roll forward too.
  • It sits below both the env var and the command line in precedence (verified), so either can still override it per invocation.
  • Worth noting for Let dotnet-script roll forward to a newer .NET runtime #2095 independently: WithDotnetRollForward intends not to override an explicit value, but checks only the passed dictionary, never Environment.GetEnvironmentVariable — and SilentProcessRunner writes that dictionary over the inherited environment. A machine-level DOTNET_ROLL_FORWARD set by a target admin is silently overridden today.

LatestMajor, not Major: Major selects the lowest higher major. On a target with .NET 9 and .NET 10 installed it picks 9 (measured). LatestMajor picks 10, and on a net8-only target still falls back to 8 — so it cannot regress a target that works today.

Commit 2 — dotnet-script 1.6.0 → 2.0.1

Heads up: this is the GitHub release zip, which is still net8-targeted with no rollForward. Only the NuGet package ships tools/net10.0. So the upgrade does not remove the need for commit 1 — verified, 2.0.1 with no policy still exits 150 on a .NET-10-only target.

On a .NET 8 SDK-only target, 2.0.1 vs 1.6.0 running the real Bootstrap.csx plus 17 BCL idiom probes: identical apart from the machine name, both exit 0. Two things do change, and both reach net8 customers too:

  1. Isolated assembly load context is now the default. A script referencing a package dotnet-script also carries used to bind to dotnet-script's copy — #r "nuget: Newtonsoft.Json, 9.0.1" resolved to 13.0.0.0 — and under isolation it gets 9.0.1. This commit passes --disable-isolated-load-context to hold existing behaviour, skipped when the caller already passed --isolated-load-context: when both flags are present the disable wins, so adding ours unconditionally would override the customers who deliberately opted in. The existing test cases show customers do pass it. Two new tests cover both branches.
  2. Roslyn 4.11 → 5.0.0-2.final raises the C# language ceiling independent of runtime. The C# 14 field keyword fails to compile on 1.6.0 and compiles on 2.0.1, on the same .NET 8. A widening rather than a break, but one-way: scripts written against 2.0.1 won't compile if the bundle is rolled back.

Should this be one PR?

My recommendation is no — commit 2 should ship in its own release. Commit 1 is reversible and measured-nil in script behaviour; commit 2 carries two semantic changes that land on net8 and net10 targets alike. Shipping them together makes any resulting support ticket ambiguous between "the runtime moved" and "the tool moved". They are deliberately separate commits so git cherry-pick splits them cleanly. Combined here because that is what was asked for.

Verification

Container matrix over controlled .NET install states (arm64, Linux), driving the vendored tool and the built artefact. Raw output and harness in the CalamariDotNet10 project: test-plan/harness/dotnet-script/runmode.sh, out/runtime-policy-matrix.txt, out/ds201-on-net8.txt.

Not verified: Windows end-to-end (the runtimeconfig mechanism is platform-independent by construction, but that is reasoning, not measurement); amd64; the locally-installed-dotnet-script-on-PATH branch of DotnetScriptExecutor, which none of these mechanisms reach. The ALC finding is one package pair, not a survey.

🤖 Generated with Claude Code

NickJosevski and others added 2 commits August 12, 2026 15:27
dotnet-script is a framework-dependent application requesting
Microsoft.NETCore.App 8.0.0, and the runtimeconfig.json in the zip we vendor
sets no rollForward. The default Minor policy therefore binds to an 8.x runtime
and nothing else, which has two consequences on customer targets:

  - a target with only a newer runtime cannot run C# script steps at all
    (exit 150, "You must install or update .NET to run this application")
  - a target with both 8.x and a newer runtime silently stays on .NET 8, so
    scripts keep compiling against the .NET 8 reference set

Both are live today; neither is caused by the net10 migration. The second one
is why every Octopus Cloud dynamic worker runs C# scripts on .NET 8 even though
.NET 10 is installed alongside it.

This overwrites the extracted runtimeconfig.json with a copy carrying
"rollForward": "LatestMajor", so the host binds to the newest major installed
on the target and falls back cleanly when there is nothing newer.

Verified against the built artefact, no environment variable set:

  .NET 8 SDK + .NET 10 runtime  ->  10.0.10   (was 8.0.29)
  .NET 10 SDK only              ->  10.0.10   (was exit 150)
  .NET 8 SDK only               ->  8.0.29    (unchanged)

Chosen over DOTNET_ROLL_FORWARD because the host reads runtimeconfig.json on
every launch path - including the Windows dotnet-script.cmd - so no change to
the invocation is needed on either platform, and the policy cannot leak into
processes the customer's script starts. It also sits below both the environment
variable and the command line in precedence, so either can still override it.

LatestMajor rather than Major: Major selects the *lowest* higher major, so on a
target with .NET 9 and .NET 10 installed it would pick 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the vendored release zip. Note this is the GitHub release zip, which is
still net8-targeted with no rollForward - only the NuGet package ships
tools/net10.0 - so this upgrade does not remove the need for the roll-forward
policy in the previous commit. Verified: 2.0.1 with no policy still exits 150 on
a .NET-10-only target, exactly as 1.6.0 did.

Measured on a .NET 8 SDK-only target, 2.0.1 vs 1.6.0: the real Bootstrap.csx
plus 17 BCL idiom probes are byte-identical apart from the machine name, and
both exit 0. Two things do change, and both reach net8 customers as well as
net10 ones:

1. Isolated assembly load context is now the default. A script referencing a
   package that dotnet-script also carries used to bind to dotnet-script's copy
   (#r "nuget: Newtonsoft.Json, 9.0.1" resolved to 13.0.0.0); under isolation it
   gets the version it asked for. This commit passes
   --disable-isolated-load-context to hold the existing behaviour, so the
   upgrade does not silently change what customer scripts run against.

   It is skipped when the caller already passed --isolated-load-context, which
   the existing FormatCommandArguments test cases show customers do: when both
   flags are present the disable wins, so adding ours unconditionally would
   override the customers who deliberately opted in. Covered by two new tests.

2. Roslyn moves 4.11 -> 5.0.0-2.final, which raises the C# language ceiling
   under customer scripts independent of runtime. The C# 14 `field` keyword
   fails to compile on 1.6.0 and compiles on 2.0.1, on the same .NET 8. That is
   a widening rather than a break, but it is one-way: scripts written against
   2.0.1 will not compile if the bundle is rolled back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant