Skip to content

Bundle dotnet-script 2.0.1 and set roll-forward in its runtimeconfig - #2133

Draft
NickJosevski wants to merge 1 commit into
mainfrom
nj/dotnet-script-201-rollforward
Draft

Bundle dotnet-script 2.0.1 and set roll-forward in its runtimeconfig#2133
NickJosevski wants to merge 1 commit into
mainfrom
nj/dotnet-script-201-rollforward

Conversation

@NickJosevski

Copy link
Copy Markdown
Contributor

Draft — an alternative to #2095, for discussion. Same problem, different delivery mechanism, plus the dotnet-script 2.0.1 bump on top. Everything below was measured in containers on linux/arm64; nothing is inferred.

The two changes are independent

dotnet-script is framework-dependent on Microsoft.NETCore.App 8.0.0 and declares no rollForward, so the default policy is Minor — any 8.x, nothing higher. On a target that has only a newer runtime it fails to launch:

exit 150
https://aka.ms/dotnet-core-applaunch?framework=Microsoft.NETCore.App&framework_version=8.0.0&...

The 2.0.1 zip behaves identically. Its runtimeconfig is still "tfm": "net8.0" / "version": "8.0.0" with no rollForward, and it fails with the same exit 150 and the same requested framework version. So bumping the version does not fix the launch problem, and the roll-forward fix is needed either way. (The 2.0.1 NuGet package does ship tools/net10.0, but all three tool folders are framework-dependent with no rollForward — vendoring that one would hard-require .NET 10 and take C# steps away from every net8-only customer.)

Roll-forward via runtimeconfig instead of an environment variable

Ship rollForward: LatestMajor in the vendored dotnet-script.runtimeconfig.json, copied over the zip's own copy after <Unzip> in IncludeDotNetScript.targets.

Three reasons to prefer this over DOTNET_ROLL_FORWARD:

  1. Platform-uniform, no C# change. Calamari invokes the tool differently per platform — dotnet-script.cmd on Windows, dotnet "<path>/dotnet-script.dll" on Linux. The host reads the runtimeconfig on both paths.
  2. Process-scoped. cli.EnvironmentVars is inherited by every process the customer's script starts. A script that shells out to its own framework-dependent .NET 8 tool would silently start rolling forward too.
  3. It sits below env and command line in precedence, so it is a default a step can still override rather than something Calamari imposes.

LatestMajor, not Major

Major only engages when the requested major is absent, and then picks the lowest higher major. On a box with .NET 8 and 10 — the shape of every Octopus Cloud worker — it is a no-op. LatestMajor degrades silently to 8 when nothing newer is installed, so it cannot regress a target that works today.

Also worth fixing in #2095 regardless

WithDotnetRollForward intends "don't override an explicit value — the surrounding environment may have set one deliberately", but it checks only the passed dictionary, never Environment.GetEnvironmentVariable. SilentProcessRunner.cs:100-102 then writes that dictionary over a block UseShellExecute = false has already seeded from the parent, so a machine-level DOTNET_ROLL_FORWARD set by a target administrator is silently overridden. Moving the default into the runtimeconfig removes the class of bug, since config sits below both env and command line.

What the 2.0.1 bump actually changes

Measured head-to-head against 1.6.0 on a .NET 8 SDK-only target. Launch, restore, generated TFM (net8.0), #r "nuget:", #load, AssemblyResolve, proxy, Process.Start and argument passthrough are all unchanged. Two real deltas:

1. The isolated assembly load context becomes the default (the flag inverted from --isolated-load-context to --disable-isolated-load-context). This is a single toggle with a genuine trade-off on both sides, and it is not new in 2.0 — 1.6.0 has the same behaviour behind the opt-in flag. 2.0 only flips the default:

native NuGet assets (SQLite, SkiaSharp, Microsoft.Data.SqlClient) Assembly.LoadFrom then cast
isolation off (1.6.0 default) TypeInitializationException works
isolation on (2.0 default) works InvalidCastException

Verified in all four cells, including 1.6.0 with --isolated-load-context and 2.0.1 with --disable-isolated-load-context. So taking the 2.0 default fixes dotnet-script#763 — native packages have never worked in C# script steps — at the cost of breaking scripts that hand-load an assembly and cast the result. Type identity for #r "nuget:" resolved by name is unaffected; it is specifically load-from-path-then-cast.

This PR takes upstream's default and adds an escape hatch rather than silently picking a side. This is the main thing worth a second opinion.

2. The C# language ceiling moves 13 → 14. Roslyn is vendored inside the zip with LanguageVersion.Preview, so this follows the bump, not the runtime. field keyword: CS0103 on 1.6.0, compiles and runs on 2.0.1. Not a break, but a one-way door — once a customer uses C# 14 you cannot revert the zip.

Variables added

Both are no-ops when unset.

Variable Effect
Octopus.Action.Script.CSharp.RollForward Overrides the policy for the step, via DOTNET_ROLL_FORWARD. Deliberately the env var and not the config: it outranks the runtimeconfig, and it is the only mechanism that reaches a dotnet-script the customer installed themselves — which Calamari prefers over the bundled copy.
Octopus.Action.Script.CSharp.DisableIsolatedLoadContext Restores pre-2.0 load context behaviour. 1.6.0 ignores the flag, so it is safe to pass to a customer's own locally-installed copy too.

Verification

Against the real build output (source/Calamari/bin/Debug/net8.0/dotnet-script/), no environment variables set:

Container Result
.NET 10 SDK only .NET 10.0.10, generated TFM net10.0
.NET 8 SDK only .NET 8.0.29, generated TFM net8.0 ✅ clean no-op
.NET 10 SDK only + DOTNET_ROLL_FORWARD=Minor exit 150 — confirms the override outranks the config

Calamari.Common builds with no new warnings; DotnetScriptBootstrapperFixture 10/10 pass.

Not verified

  • Windows, end to end. All measurements are Linux containers. The runtimeconfig mechanism is platform-independent by construction — dotnet-script.cmd is one line, @dotnet exec "%~dp0dotnet-script.dll" %* — but that is reasoning, not measurement. The main gap.
  • amd64. Nothing observed looks architecture-dependent, but it has not been re-run there.
  • Prerelease roll-forward. LatestMajor should not select a prerelease runtime without rollForwardToPrerelease, so a target with a .NET 11 preview should be unaffected. Untested, and the one scenario where LatestMajor could surprise us.
  • The wider 2.0.1 surface beyond the probes above — the 20-step edge-case matrix has only been run against 1.6.0.

Related

🤖 Generated with Claude Code

An alternative to #2095. Same problem, different mechanism, plus the 2.0.1
bump on top.

dotnet-script is framework-dependent on Microsoft.NETCore.App 8.0.0 with no
rollForward of its own, so on a target that has only a newer runtime it fails
to launch: exit 150, "You must install or update .NET to run this application".
This is true of the 2.0.1 zip as well, so bumping the version does not fix it
and the two changes are independent.

Rather than setting DOTNET_ROLL_FORWARD in the child process environment, ship
rollForward: LatestMajor in the vendored dotnet-script.runtimeconfig.json. The
host reads that file on every launch path, so it covers Windows
(dotnet-script.cmd) and Linux (dotnet "<path>/dotnet-script.dll") with no C#
change. It is also process-scoped: an environment variable leaks into every
process the customer's script starts, so a script shelling out to its own
framework-dependent .NET 8 tool would silently start rolling forward too.

LatestMajor rather than Major. Major is a no-op whenever an 8.x runtime is
present, which is the shape of every Octopus Cloud worker, and when it does
engage it picks the lowest higher major rather than the newest. LatestMajor
degrades silently to 8 on a net8-only target, so it cannot regress a target
that works today.

Two variables, both no-ops when unset:

- Octopus.Action.Script.CSharp.RollForward overrides the policy per step. It
  goes through DOTNET_ROLL_FORWARD, which outranks the runtimeconfig and is
  the only mechanism that reaches a dotnet-script the customer installed
  themselves, since Calamari prefers that over the bundled copy.
- Octopus.Action.Script.CSharp.DisableIsolatedLoadContext restores pre-2.0
  assembly load context behaviour. 2.0 makes the isolated context the default.
  That is what makes native NuGet assets work, but it also means a type loaded
  via Assembly.LoadFrom is no longer reference-equal to the same type in the
  script's own closure.

Verified against the real build output in containers, linux/arm64:

  net10 SDK only, no env var  -> .NET 10.0.10, generated TFM net10.0
  net8 SDK only, no env var   -> .NET 8.0.29,  generated TFM net8.0
  net10 SDK only, Minor       -> exit 150 (override beats the config)

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.

System.DllNotFoundException: Unable to load DLL 'e_sqlite3' or one of its dependencies: The specified module could not be found.

1 participant