From b5d6997cc0e357e286cec8b9e3a5e074ee123588 Mon Sep 17 00:00:00 2001 From: Kevin Martinez Date: Thu, 12 Nov 2026 15:30:11 -0500 Subject: [PATCH 1/3] fix(cli): package with the runtime pack MSBuild resolved, not the SDK's own With .NET SDK 11 installed, a net10.0 game is published with a 10.0 runtime pack, but `microengine package` looked for the licence of the SDK's bundled runtime (11.0.0) and stopped. Every target now asks MSBuild for the ResolvedRuntimePack of the publish and copies that pack's licence and notices; build-info.json records its version. The Android host gets them through a generated Runtime.props after a restore. `microengine doctor` accepts any SDK from 10 on and names the browser workload the SDK in use needs for a .NET 10 game (wasm-tools-net10 with SDK 11); `microengine info` shows the target framework. The SDK's external check now installs .NET SDK 11 explicitly and installs the workloads for that SDK from outside the repository, so the project and its workloads share one SDK; it records the SDK in use and checks the runtime each package carries. --- .github/workflows/sdk.yml | 8 +- docs/guide/cli.md | 2 +- docs/guide/getting-microengine.md | 6 +- docs/guide/packaging.md | 4 +- docs/guide/release-notes.md | 10 +++ docs/releasing.md | 6 +- docs/tutorial/12-packaging.md | 2 +- docs/tutorial/13-other-targets.md | 3 +- sdk/tests/external.mjs | 30 +++++++- src/MicroEngine.Cli/Doctor.cs | 27 +++++-- src/MicroEngine.Cli/GameProject.cs | 8 +- src/MicroEngine.Cli/Hosts.cs | 56 ++++++-------- src/MicroEngine.Cli/Hosts/android/Host.csproj | 7 +- src/MicroEngine.Cli/Packaging.cs | 74 ++++++++++++++----- src/MicroEngine.Cli/Program.cs | 2 +- tests/MicroEngine.Cli.Tests/NoticesTests.cs | 44 +++++++---- tests/MicroEngine.Cli.Tests/WorkloadTests.cs | 16 ++++ 17 files changed, 217 insertions(+), 88 deletions(-) create mode 100644 tests/MicroEngine.Cli.Tests/WorkloadTests.cs diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index dc13abdd..6381a1b8 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -118,9 +118,13 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref || github.sha }} + # The external project lives outside the repository, so global.json does not choose its SDK: the newest installed + # one builds it, as for a developer who installs the latest .NET. Installing that major here, instead of taking + # whatever the runner image ships, keeps every runner on the same SDK while GitHub rolls out new images. - uses: actions/setup-dotnet@v4 with: global-json-file: global.json + dotnet-version: 11.0.x - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} @@ -132,7 +136,9 @@ jobs: if: matrix.os == 'Linux' run: | sudo apt-get update -qq && sudo apt-get install -y -qq xvfb > /dev/null - dotnet workload install wasm-tools android > /dev/null + # Workloads belong to one SDK: install them outside the repository, for the SDK the external project uses. + # SDK 11 builds the net10.0 web host with wasm-tools-net10 (wasm-tools serves net11.0 only), as `microengine doctor web` says. + (cd "$RUNNER_TEMP" && dotnet --version && dotnet workload install wasm-tools-net10 android > /dev/null) npm ci --prefix web/tests > /dev/null - uses: actions/setup-java@v4 if: matrix.os == 'Linux' diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 92c2cbf2..bad6d5db 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -51,7 +51,7 @@ Builds a file a player installs, into `dist/` next to the project (or `--out`), | `android` | APK for arm64-v8a and x86_64, signed with your key | any system | `android` workload, Android SDK, Java 17+ | | `ios` | `.ipa` for iPhone and iPad, signed for your devices or the App Store | macOS | Xcode, `ios` workload, Apple Developer account | | `ios-simulator` | ZIP holding the `.app` for Xcode's Simulator; **not installable on a device** | macOS | Xcode, `ios` workload | -| `web` | a static site (folder and ZIP) for any web server | any system | `wasm-tools` workload | +| `web` | a static site (folder and ZIP) for any web server | any system | `wasm-tools` workload (`wasm-tools-net10` with .NET SDK 11) | [Packaging](packaging.md) describes each package, its requirements and limits; [Signing](signing.md) the keys. diff --git a/docs/guide/getting-microengine.md b/docs/guide/getting-microengine.md index 0fecaa25..ab7c4f6d 100644 --- a/docs/guide/getting-microengine.md +++ b/docs/guide/getting-microengine.md @@ -20,8 +20,10 @@ terminal: dotnet --version ``` -It should print `10.0.100` or later. Desktop games need nothing else: SDL 3 and raylib come inside the NuGet packages. -Android, iOS and browser packages need extra .NET workloads; `microengine doctor` (step 4) lists them when you need them. +It should print `10.0.100` or later. A newer SDK, such as .NET 11, builds .NET 10 games too, and their packages carry +the .NET 10 runtime. Desktop games need nothing else: SDL 3 and raylib come inside the NuGet packages. Android, iOS and +browser packages need extra .NET workloads, installed for the SDK that builds your games (each SDK has its own); +`microengine doctor` (step 4) names the ones that SDK needs. ## 2. Install the `microengine` tool diff --git a/docs/guide/packaging.md b/docs/guide/packaging.md index a1d2d4f5..b7d65111 100644 --- a/docs/guide/packaging.md +++ b/docs/guide/packaging.md @@ -63,6 +63,8 @@ SDL). Upload the folder to any static host (GitHub Pages, Netlify, an S3 bucket, pre-compressed (`.br`, `.gz`) for servers that serve them. The game runs on SDL 3 compiled to WebAssembly, in the same page in desktop and mobile browsers, with touch controls. -It needs the `wasm-tools` workload (`dotnet workload install wasm-tools`). SDL for the browser comes inside the +It needs the `wasm-tools` workload (`dotnet workload install wasm-tools`); with a newer .NET SDK than the game's +.NET 10, the workload for .NET 10 instead (`dotnet workload install wasm-tools-net10` with .NET SDK 11), because +`wasm-tools` serves only the SDK's own .NET. `microengine doctor web` names the one your SDK needs. SDL for the browser comes inside the `MicroEngine.Sdl` package, built with the same emscripten as that workload, so any game that references `MicroEngine.Sdl` or `MicroEngine.Desktop` can be packaged for the web (`--sdl-wasm FILE` names another SDL library). diff --git a/docs/guide/release-notes.md b/docs/guide/release-notes.md index 387b55e2..1353a994 100644 --- a/docs/guide/release-notes.md +++ b/docs/guide/release-notes.md @@ -24,6 +24,16 @@ An eighth package: `MicroEngine.Physics.Box2D`, optional, for dynamic bodies. Android and iOS packages from the game's `Configure(HostBuilder)` entry point only, and stops with the method to add when a game has none. The tutorial's Gem Garden declares the entry point from its first step. +**Fixed** + +- `microengine package` works with a .NET SDK newer than the game's .NET 10, such as .NET SDK 11: Windows and Linux + packages stopped because the tool looked for the licence of the SDK's own runtime (11.0) instead of the .NET 10 + runtime the build used. Every package now carries the licence and notices of the runtime pack the build resolved, + and `build-info.json` names that runtime's version. +- `microengine doctor` accepts any .NET SDK from 10 on, and `doctor web` names the workload that SDK needs for a .NET 10 + game: `wasm-tools-net10` with .NET SDK 11. `microengine info` shows the game's target framework (`net10.0`) instead + of the SDK's runtime version. + ## 0.5.0-alpha.2 UI primitives for any game's interface, and a seventh package: `MicroEngine.Text.Shaping`, optional, for Arabic, diff --git a/docs/releasing.md b/docs/releasing.md index fe61560d..4ffc6b37 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -90,7 +90,11 @@ Daily CI runs the SDK check (`sdk.yml`) whenever a package input changes, and ** captures before and after must differ: input, drawing and a sprite from Content), packages it for each target the runner can build (Windows, Linux, macOS, Android, web), checks each package's files and metadata, runs the desktop package, plays the web package in Chrome, and checks the failures a developer meets (invalid id, missing or invalid - icon, missing signing key, a target the system cannot build, a missing workload). + icon, missing signing key, a target the system cannot build, a missing workload). The repository's `global.json` + does not reach that folder, so the newest installed .NET SDK builds the game, as on a developer's machine: the + workflow installs .NET SDK 11 next to the repository's SDK 10, and the Linux runner installs the workloads for + SDK 11 from outside the repository (`wasm-tools-net10`, `android`), because workloads belong to one SDK. SDK 10 is + covered by the repository's own package checks. 3. **The documented samples** (`sdk/tests/docs.mjs`, in the .NET Linux job): each sample the site quotes is copied outside the repository and built against the packages this commit packs (the SDK built from source), and, once the documented version is public, also from nuget.org at that version. The nuget.org build is only reported while the diff --git a/docs/tutorial/12-packaging.md b/docs/tutorial/12-packaging.md index c8c47793..1903777d 100644 --- a/docs/tutorial/12-packaging.md +++ b/docs/tutorial/12-packaging.md @@ -69,7 +69,7 @@ Orientation landscape Factory new GemGarden.MainGame() Content ...\GemGarden\Content\ Licence (none) -Engine MicroEngine 0.5.0-alpha.2, .NET 10.0.14 +Engine MicroEngine 0.5.0-alpha.2, net10.0 ``` `Licence (none)`: a `LICENSE`, `LICENSE.txt` or `LICENSE.md` file next to the project is shipped in every package as diff --git a/docs/tutorial/13-other-targets.md b/docs/tutorial/13-other-targets.md index 9f3517fd..759406ed 100644 --- a/docs/tutorial/13-other-targets.md +++ b/docs/tutorial/13-other-targets.md @@ -34,7 +34,8 @@ microengine package web ``` The first command installs .NET's WebAssembly build tools once per machine (on Windows, from an administrator -terminal if .NET was installed for all users). The package is a folder and a ZIP of the same files: +terminal if .NET was installed for all users). With a .NET 11 SDK, install `wasm-tools-net10` instead: the tools for +the game's .NET 10 (`microengine doctor web` says which one your SDK needs). The package is a folder and a ZIP of the same files: `dist/GemGarden-1.0.0-web/`, a static site with an `index.html` titled "Gem Garden", the icon as favicon, the .NET runtime compiled to WebAssembly, SDL 3, the game and its `Content`. diff --git a/sdk/tests/external.mjs b/sdk/tests/external.mjs index ec1f9e82..c840ce8a 100644 --- a/sdk/tests/external.mjs +++ b/sdk/tests/external.mjs @@ -30,8 +30,17 @@ const fromPublic = args.includes('--public'); const feed = fromPublic ? null : path.resolve(option('feed') ?? path.join(repo, 'dist', 'sdk-feed')); const version = option('version') ?? fs.readFileSync(path.join(repo, 'Directory.Build.props'), 'utf8').match(/([^<]+) spawnSync('dotnet', list, { cwd: outsideRepo, encoding: 'utf8' }).stdout ?? ''; +const sdkVersion = dotnetOutside(['--version']).trim(); +// The browser workload for the template's net10.0 with that SDK, as `microengine doctor web` names it: wasm-tools serves +// only the SDK's own framework, so SDK 11 needs wasm-tools-net10. +const webWorkload = Number(sdkVersion.split('.')[0]) > 10 ? 'wasm-tools-net10' : 'wasm-tools'; +const hasWorkload = (list, name) => new RegExp(`^\\s*${name}\\s`, 'm').test(list); // By default: this system's own desktop target, Linux (cross-built from anywhere) and the web when the workload is here. -const hasWasm = /^\s*wasm-tools\s/m.test(spawnSync('dotnet', ['workload', 'list'], { encoding: 'utf8' }).stdout ?? ''); +const hasWasm = hasWorkload(dotnetOutside(['workload', 'list']), webWorkload); const targets = (option('targets') ?? [host, 'linux', ...(hasWasm ? ['web'] : [])].join(',')).split(',').filter((t, i, all) => t && all.indexOf(t) === i); const exe = process.platform === 'win32' ? '.exe' : ''; @@ -47,9 +56,9 @@ if (feed && !fs.existsSync(feed) || feed && !fs.readdirSync(feed).some(f => f.st console.error(`error: ${feed} holds no MicroEngine.Cli ${version} package; run: dotnet pack MicroEngine.slnx -c Release -o ${feed}`); process.exit(2); } -const work = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'microengine-external-')); +const work = fs.mkdtempSync(path.join(outsideRepo, 'microengine-external-')); if (work.startsWith(repo)) throw new Error('the external project must live outside the repository'); -console.log(`external project in ${work}, SDK ${version} from ${feed ?? 'nuget.org only'}`); +console.log(`external project in ${work}, SDK ${version} from ${feed ?? 'nuget.org only'}, .NET SDK ${sdkVersion}`); const env = { ...process.env, NUGET_PACKAGES: path.join(work, 'nuget'), DOTNET_CLI_HOME: path.join(work, 'home'), DOTNET_NOLOGO: '1', DOTNET_CLI_TELEMETRY_OPTOUT: '1' }; for (const secret of ['ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD', 'MICROENGINE_IOS_CODESIGN_KEY', 'MICROENGINE_IOS_PROVISIONING_PROFILE']) delete env[secret]; delete env.MICROENGINE_DOTNET; @@ -178,7 +187,9 @@ try { } if (process.platform !== 'win32') expectFailure('Windows package on another system', null, ['package', 'windows'], /built on Windows/); const workloads = run('dotnet', ['workload', 'list']).out; - if (!/^\s*wasm-tools\s/m.test(workloads)) expectFailure('web package without the wasm-tools workload', null, ['package', 'web'], /wasm-tools/); + if (!hasWorkload(workloads, webWorkload)) expectFailure(`web package without the ${webWorkload} workload`, null, ['package', 'web'], new RegExp(webWorkload)); + r = run(me, ['doctor', 'web'], { cwd: game }); + check(`microengine doctor web names the ${webWorkload} workload for .NET SDK ${sdkVersion}`, new RegExp(`${webWorkload} workload: (installed|dotnet workload install ${webWorkload})`).test(r.out), tail(r.out)); // Only a case-sensitive file system can hold both names; the build refuses them so every system loads the same file. if (process.platform === 'linux') { const twin = path.join(game, 'Content', 'Hero.png'); @@ -274,6 +285,14 @@ async function checkGallery() { } } +// A package carries the runtime of the game's target framework (net10.0: a 10.0 runtime) with its licence and notices, +// whichever SDK built it: SDK 11 publishes a net10.0 game with the 10.0 runtime, not its own 11.0. +function checkRuntime(target, info, has) { + const major = fs.readFileSync(path.join(work, 'Sample', 'Sample.csproj'), 'utf8').match(/net(\d+)\.\d+ fs.existsSync(path.join(top, rel)); + checkRuntime(target, info, name => has(`licenses/${name}`)); const code = target === 'macos' ? path.join(top, 'Sample Game.app', 'Contents', 'MacOS') : top; check(`${target}: executable, content, notices and README`, fs.existsSync(path.join(code, `${id}${target === 'windows' ? '.exe' : ''}`)) && has('README.txt') && has('THIRD-PARTY-NOTICES.txt') && has('licenses/microengine-LICENSE.txt') && (target === 'macos' ? has('Sample Game.app/Contents/Resources/Content/hero.png') : has('Content/hero.png'))); @@ -315,6 +335,7 @@ function checkPackage(target, file, dist) { const manifest = JSON.parse(fs.readFileSync(path.join(folder, 'manifest.webmanifest'), 'utf8')); check('web: app manifest and start screen', manifest.name === 'Sample Game' && manifest.icons?.[0]?.src === 'icon.png' && /rel="manifest"/.test(index) && / fs.existsSync(path.join(folder, 'licenses', name))); webPlays.push(folder); } else if (target === 'android') { const sdk = process.env.ANDROID_HOME; @@ -328,6 +349,7 @@ function checkPackage(target, file, dist) { const listing = run(path.join(tools, latest, `aapt${exe}`), ['list', file]).out; check('android: adaptive icon, round icon and launch screen resources', ['res/mipmap-anydpi-v26/appicon_round.xml', 'res/mipmap-xxxhdpi-v4/appicon_foreground.png', 'res/mipmap-mdpi-v4/appicon.png', 'res/drawable/splash.xml'] .every(r => listing.includes(r)), tail(listing)); + checkRuntime('android', JSON.parse(fs.readFileSync(file.replace(/\.apk$/, '.build-info.json'), 'utf8')), name => listing.includes(`assets/licenses/${name}`)); if (args.includes('--device')) { const adb = path.join(sdk, 'platform-tools', `adb${exe}`); const installed = run(adb, ['install', '-r', file]); diff --git a/src/MicroEngine.Cli/Doctor.cs b/src/MicroEngine.Cli/Doctor.cs index 7b4f8254..edfb13a8 100644 --- a/src/MicroEngine.Cli/Doctor.cs +++ b/src/MicroEngine.Cli/Doctor.cs @@ -8,18 +8,34 @@ public static class Doctor { private sealed record Item(string Name, bool Ok, string Detail); + /// The .NET the engine, the template and the tool's hosts target (net10.0). + public const int FrameworkMajor = 10; + + /// + /// The workload that builds browser packages of a net.0 game with a .NET SDK of major + /// version : wasm-tools serves the SDK's own framework only, so a newer SDK needs the + /// framework's own (SDK 11 builds net10.0 games with wasm-tools-net10). + /// + public static string WebWorkload(int sdk, int framework = FrameworkMajor) => sdk > framework ? $"wasm-tools-net{framework}" : "wasm-tools"; + + /// The major version of a .NET SDK version (11 for 11.0.100), or 0 when it is not one. + public static int Major(string version) => int.TryParse(version.Trim().Split('.')[0], out int major) ? major : 0; + public static bool Check(string? only) { PackageTarget? target = only is null ? null : PackageTarget.Find(only); string workloads = ""; - string sdks = ""; + string sdk = ""; try { - sdks = Tool.Run(Tool.Dotnet, ["--list-sdks"], what: "dotnet --list-sdks"); + // The SDK that builds games here (a global.json can choose it) and the workloads installed for it: each + // SDK has its own. + sdk = Tool.Run(Tool.Dotnet, ["--version"], what: "dotnet --version").Trim(); workloads = Tool.Run(Tool.Dotnet, ["workload", "list"], what: "dotnet workload list"); } catch (CliException) { } - bool dotnet = sdks.Split('\n').Any(l => l.StartsWith("10.", StringComparison.Ordinal)); + bool dotnet = Major(sdk) >= FrameworkMajor; + string web = WebWorkload(Major(sdk)); bool Workload(string name) => workloads.Split('\n').Any(l => l.TrimStart().StartsWith(name + " ", StringComparison.Ordinal)); bool mac = OperatingSystem.IsMacOS(); string? androidSdk = AndroidSdk(); @@ -29,7 +45,8 @@ public static bool Check(string? only) var groups = new List<(string Target, List Checks)> { ("all", [ - new(".NET 10 SDK", dotnet, dotnet ? sdks.Split('\n').Last(l => l.StartsWith("10.", StringComparison.Ordinal)).Split(' ')[0] : "install it from https://dotnet.microsoft.com/download (MICROENGINE_DOTNET can name a dotnet elsewhere)"), + new(".NET SDK", dotnet, dotnet ? $"{sdk} (builds .NET {FrameworkMajor} games)" + : $"install the .NET {FrameworkMajor} SDK or a newer one from https://dotnet.microsoft.com/download (MICROENGINE_DOTNET can name a dotnet elsewhere)"), new("git", Tool.Exists("git"), Tool.Exists("git") ? "build-info records the commit" : "optional: packages then record the commit as unknown"), ]), ("windows", [new("Windows host", OperatingSystem.IsWindows(), OperatingSystem.IsWindows() ? "can write the executable's icon and version" : "Windows packages are built on Windows")]), @@ -50,7 +67,7 @@ public static bool Check(string? only) new("device signing", Set("MICROENGINE_IOS_CODESIGN_KEY") && Set("MICROENGINE_IOS_PROVISIONING_PROFILE"), "MICROENGINE_IOS_CODESIGN_KEY and MICROENGINE_IOS_PROVISIONING_PROFILE from an Apple Developer account; ios-simulator needs neither"), ]), - ("web", [new("wasm-tools workload", Workload("wasm-tools"), Workload("wasm-tools") ? "installed" : "dotnet workload install wasm-tools")]), + ("web", [new($"{web} workload", Workload(web), Workload(web) ? "installed" : $"dotnet workload install {web}")]), }; string? wanted = target?.Name == "ios-simulator" ? "ios" : target?.Name; bool ok = true; diff --git a/src/MicroEngine.Cli/GameProject.cs b/src/MicroEngine.Cli/GameProject.cs index 0b4e1849..690e12df 100644 --- a/src/MicroEngine.Cli/GameProject.cs +++ b/src/MicroEngine.Cli/GameProject.cs @@ -15,7 +15,7 @@ public sealed partial class GameProject "AssemblyName", "ApplicationTitle", "ApplicationId", "Version", "Description", "MicroEngineIcon", "MicroEngineOrientation", "MicroEngineContentDir", "MicroEngineScriptsDir", "MicroEngineLicenseFile", "MicroEngineThirdPartyNotices", "MicroEngineVersion", "MicroEngineAppTargets", "MicroEngineAndroidMinApi", "OutputType", "TargetFramework", "TargetDir", - "BundledNETCoreAppPackageVersion", "MicroEngineWebSdlFromPackage", "NetCoreRoot", + "MicroEngineWebSdlFromPackage", "NetCoreRoot", "NETCoreSdkVersion", "MicroEngineTextShaping", "MicroEngineSplash", "MicroEngineSplashColor", "MicroEngineSplashImage", "MicroEngineSplashBranding", "MicroEngineIconBackground", ]; @@ -39,7 +39,11 @@ private GameProject(string path, Dictionary values) public string Description => this["Description"]; public string Orientation => this["MicroEngineOrientation"].Length > 0 ? this["MicroEngineOrientation"] : "landscape"; public string EngineVersion => this["MicroEngineVersion"].Length > 0 ? this["MicroEngineVersion"] : "unknown"; - public string DotnetVersion => this["BundledNETCoreAppPackageVersion"]; + /// The .NET the game targets (TargetFramework, for example net10.0); packages carry that framework's runtime. + public string TargetFramework => this["TargetFramework"]; + /// The workload that builds this game's browser packages with the SDK that builds it (). + public string WebWorkload => Doctor.WebWorkload(Doctor.Major(this["NETCoreSdkVersion"]), + int.TryParse(TargetFramework.Replace("net", "").Split('.')[0], out int framework) ? framework : Doctor.FrameworkMajor); public string NetCoreRoot => this["NetCoreRoot"]; public string AndroidMinApi => this["MicroEngineAndroidMinApi"].Length > 0 ? this["MicroEngineAndroidMinApi"] : "24"; /// The SDL3.a the MicroEngine.Sdl package carries for browser builds, when the game references the package. diff --git a/src/MicroEngine.Cli/Hosts.cs b/src/MicroEngine.Cli/Hosts.cs index 52b1afcf..ff8c7e2a 100644 --- a/src/MicroEngine.Cli/Hosts.cs +++ b/src/MicroEngine.Cli/Hosts.cs @@ -61,13 +61,14 @@ public static async Task Web(PackageRequest request) if (Directory.Exists(publish)) Directory.Delete(publish, recursive: true); Console.WriteLine($"Building {game.Title} for browsers (WebAssembly; the first build takes a few minutes)..."); Tool.Run(Tool.Dotnet, ["publish", Path.Combine(host, "Host.csproj"), "-c", "Release", "-p:MicroEngineWebPackage=true", "-o", publish, "-nologo"], host, "the browser build"); + var runtime = Packaging.RuntimePack(Path.Combine(host, "Host.csproj"), request.Target.Rid, ["-p:MicroEngineWebPackage=true"]); string stage = Directory.CreateTempSubdirectory("microengine-package-").FullName; try { string top = Path.Combine(stage, game.Id); CopyTree(Path.Combine(publish, "wwwroot"), top); - var info = Packaging.BuildInfo(request, "none"); - WebNotices(top, game); + var info = Packaging.BuildInfo(request, "none", runtime.Version); + WebNotices(top, game, runtime); File.WriteAllText(Path.Combine(top, "build-info.json"), JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true }) + "\n"); Directory.CreateDirectory(request.Out); // The folder to upload to any static web server, and the same files as one archive. @@ -86,27 +87,9 @@ public static async Task Web(PackageRequest request) private static string WebTools => Environment.GetEnvironmentVariable("MICROENGINE_WEB_TOOLS") is { Length: > 0 } w ? w : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".microengine", "web-tools"); - /// - /// The folder of the workload pack under : the SDK's own runtime - /// version () when it is installed, otherwise the newest version installed. An SDK released - /// before its workload set bundles a newer runtime than the packs the workload installs (SDK 10.0.404 with runtime - /// 10.0.15 installs the 10.0.14 packs of workload set 10.403), and the build uses the installed ones. With no pack at - /// all, the expected path, for the error message. - /// - public static string WorkloadPack(string dotnetRoot, string pack, string version) - { - string folder = Path.Combine(dotnetRoot, "packs", pack), preferred = Path.Combine(folder, version); - if (File.Exists(Path.Combine(preferred, "LICENSE.TXT")) || !Directory.Exists(folder)) return preferred; - return Directory.GetDirectories(folder) - .Where(d => File.Exists(Path.Combine(d, "LICENSE.TXT"))) - .Select(d => (Dir: d, Version: Version.TryParse(Path.GetFileName(d).Split('-')[0], out var v) ? v : new Version(0, 0))) - .OrderByDescending(p => p.Version).Select(p => p.Dir).FirstOrDefault() ?? preferred; - } - - private static void WebNotices(string top, GameProject game) + private static void WebNotices(string top, GameProject game, DotnetRuntime runtime) { string dotnetRoot = game.NetCoreRoot; - string runtime = WorkloadPack(dotnetRoot, "Microsoft.NETCore.App.Runtime.Mono.browser-wasm", game.DotnetVersion); string? emscripten = Directory.Exists(Path.Combine(dotnetRoot, "packs")) ? Directory.GetDirectories(Path.Combine(dotnetRoot, "packs"), "Microsoft.NET.Runtime.Emscripten.*.Sdk.*") .SelectMany(d => Directory.GetFiles(d, "LICENSE", SearchOption.AllDirectories)).FirstOrDefault(f => f.Replace('\\', '/').EndsWith("tools/emscripten/LICENSE", StringComparison.Ordinal)) @@ -122,9 +105,9 @@ private static void WebNotices(string top, GameProject game) // HarfBuzz is linked into the page's WebAssembly when the game shapes text (its binding is then in the package). if (Directory.GetFiles(Path.Combine(top, "_framework"), "HarfBuzzSharp.*").Length > 0) engines.Add(EngineNotices.HarfBuzz); foreach (string engine in engines) File.Copy(Path.Combine(Tool.Home, "Notices", engine), Path.Combine(top, "licenses", engine), overwrite: true); - foreach (var (from, to) in new[] { (Path.Combine(runtime, "LICENSE.TXT"), "dotnet-LICENSE.txt"), (Path.Combine(runtime, "THIRD-PARTY-NOTICES.TXT"), "dotnet-THIRD-PARTY-NOTICES.txt"), (emscripten, "emscripten-LICENSE.txt") }) + foreach (var (from, to) in new[] { (runtime.Licence, "dotnet-LICENSE.txt"), (runtime.Notices, "dotnet-THIRD-PARTY-NOTICES.txt"), (emscripten, "emscripten-LICENSE.txt") }) { - if (from is null || !File.Exists(from)) throw new CliException($"a licence file of the browser toolchain is missing ({from ?? "emscripten's LICENSE"}); is the wasm-tools workload installed in {dotnetRoot}?"); + if (from is null || !File.Exists(from)) throw new CliException($"a licence file of the browser toolchain is missing ({from ?? "emscripten's LICENSE"}); is the {game.WebWorkload} workload installed in {dotnetRoot}? (dotnet workload install {game.WebWorkload})"); File.Copy(from, Path.Combine(top, "licenses", to), overwrite: true); } } @@ -139,8 +122,6 @@ public static Task Android(PackageRequest request, int versionCode) string host = Stage("android", game, new() { ["GameAndroidMinApi"] = game.AndroidMinApi, - // The runtime pack the Android workload installed, whose licence and notices the APK carries. - ["GameAndroidRuntimePack"] = WorkloadPack(game.NetCoreRoot, "Microsoft.NETCore.App.Runtime.Mono.android-arm64", game.DotnetVersion) + Path.DirectorySeparatorChar, ["GameVersionCode"] = versionCode.ToString(System.Globalization.CultureInfo.InvariantCulture), }, $" public const global::Android.Content.PM.ScreenOrientation ScreenOrientation = global::Android.Content.PM.ScreenOrientation.{game.Orientation switch { "portrait" => "SensorPortrait", "any" => "FullSensor", _ => "SensorLandscape" }};"); string res = Path.Combine(host, "Resources"); @@ -154,17 +135,27 @@ public static Task Android(PackageRequest request, int versionCode) AndroidSplash(game, res, iconImage); string publish = host + "-publish"; if (Directory.Exists(publish)) Directory.Delete(publish, recursive: true); - var args = new List { "publish", Path.Combine(host, "Host.csproj"), "-c", "Release", "-p:MicroEngineNativePackage=true", "-o", publish, "-nologo" }; - if (Environment.GetEnvironmentVariable("ANDROID_HOME") is { Length: > 0 } sdk) args.Add($"-p:AndroidSdkDirectory={sdk}"); - if (Environment.GetEnvironmentVariable("JAVA_HOME") is { Length: > 0 } java) args.Add($"-p:JavaSdkDirectory={java}"); - args.AddRange(signing.Arguments); + string project = Path.Combine(host, "Host.csproj"); + var properties = new List { "-p:MicroEngineNativePackage=true" }; + if (Environment.GetEnvironmentVariable("ANDROID_HOME") is { Length: > 0 } sdk) properties.Add($"-p:AndroidSdkDirectory={sdk}"); + if (Environment.GetEnvironmentVariable("JAVA_HOME") is { Length: > 0 } java) properties.Add($"-p:JavaSdkDirectory={java}"); Console.WriteLine($"Building {game.Title} for Android ({signing.Kind} signing)..."); - Tool.Run(Tool.Dotnet, args, host, "the Android build", secrets: signing.Secrets); + // The APK carries the licence and notices of the runtime pack the build uses, which MSBuild resolves once the + // host is restored (the publish's own restore then has nothing to do). + string runtimeProps = Path.Combine(host, "Runtime.props"); + File.Delete(runtimeProps); + Tool.Run(Tool.Dotnet, ["restore", project, .. properties, "-nologo"], host, "the Android build (restore)"); + var runtime = Packaging.RuntimePack(project, request.Target.Rid.Split(';')[0], properties); + File.WriteAllText(runtimeProps, "\n \n \n" + + $" \n" + + $" \n" + + " \n\n"); + Tool.Run(Tool.Dotnet, ["publish", project, "-c", "Release", "-o", publish, "-nologo", .. properties, .. signing.Arguments], host, "the Android build", secrets: signing.Secrets); string apk = Directory.GetFiles(publish, "*-Signed.apk").FirstOrDefault() ?? throw new CliException("the Android build produced no signed APK."); Directory.CreateDirectory(request.Out); string file = Path.Combine(request.Out, request.FileName); File.Copy(apk, file, overwrite: true); - var info = Packaging.BuildInfo(request, signing.Kind); + var info = Packaging.BuildInfo(request, signing.Kind, runtime.Version); info["versionCode"] = versionCode.ToString(System.Globalization.CultureInfo.InvariantCulture); File.WriteAllText(Path.ChangeExtension(file, ".build-info.json"), JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true }) + "\n"); if (signing.Kind == "debug") Packaging.Warn("this APK is signed with this machine's debug key: install it for testing, never distribute it (docs/guide/signing.md)."); @@ -208,9 +199,10 @@ public static async Task Ios(PackageRequest request, int versionCode) if (device) args.AddRange(["-o", publish, $"-p:CodesignKey={key}", $"-p:CodesignProvision={profile}"]); Console.WriteLine($"Building {game.Title} for {request.Target.Label}..."); Tool.Run(Tool.Dotnet, args, host, "the iOS build"); + var runtime = Packaging.RuntimePack(Path.Combine(host, "Host.csproj"), request.Target.Rid, ["-p:MicroEngineNativePackage=true"]); Directory.CreateDirectory(request.Out); string file = Path.Combine(request.Out, request.FileName); - var info = Packaging.BuildInfo(request, device ? "apple" : "simulator"); + var info = Packaging.BuildInfo(request, device ? "apple" : "simulator", runtime.Version); info["versionCode"] = versionCode.ToString(System.Globalization.CultureInfo.InvariantCulture); if (device) { diff --git a/src/MicroEngine.Cli/Hosts/android/Host.csproj b/src/MicroEngine.Cli/Hosts/android/Host.csproj index 54af7204..e2b6d698 100644 --- a/src/MicroEngine.Cli/Hosts/android/Host.csproj +++ b/src/MicroEngine.Cli/Hosts/android/Host.csproj @@ -4,6 +4,9 @@ folder and writes Game.props (identity and paths), GameEntry.cs (how to create the game), the launcher name and the icon; nothing here names a particular game. --> + + net10.0-android $(GameAndroidMinApi) @@ -36,9 +39,5 @@ - - - diff --git a/src/MicroEngine.Cli/Packaging.cs b/src/MicroEngine.Cli/Packaging.cs index 0283c59e..45218968 100644 --- a/src/MicroEngine.Cli/Packaging.cs +++ b/src/MicroEngine.Cli/Packaging.cs @@ -42,6 +42,22 @@ public sealed record PackageRequest(GameProject Game, PackageTarget Target, stri public string FileName => $"{Game.Id}-{Game.Version}-{Target.Id}.{Target.Archive}"; } +/// A .NET runtime pack a package carries: its folder and version. +public sealed record DotnetRuntime(string Directory, string Version) +{ + /// The runtime's licence, which travels with every package. + public string Licence => Required("LICENSE.TXT"); + /// The runtime's third-party notices, which travel with every package. + public string Notices => Required("THIRD-PARTY-NOTICES.TXT"); + + private string Required(string name) + { + string file = Path.Combine(Directory, name); + return File.Exists(file) ? file + : throw new CliException($"{file} is missing: the .NET {Version} runtime pack in {Directory} is incomplete; delete that folder and package again to restore it."); + } +} + /// Builds desktop packages (docs/guide/packaging.md): a self-contained single-file executable with its native /// libraries, content, notices, README.txt and build-info.json in one top folder named after the game; on macOS an /// application bundle. @@ -93,8 +109,9 @@ public static async Task Desktop(PackageRequest request) File.WriteAllText(Path.Combine(top, $"{desktopId}.desktop"), DesktopEntry(game, icon is not null)); } } - var info = BuildInfo(request, signing); - WriteCommon(top, game, t, info); + var runtime = RuntimePack(game.Path, t.Rid); + var info = BuildInfo(request, signing, runtime.Version); + WriteCommon(top, game, t, runtime, info); Directory.CreateDirectory(request.Out); string file = Path.Combine(request.Out, request.FileName); if (t.Name == "macos" && MacSigning.NotaryProfile is { } profile) @@ -116,22 +133,23 @@ public static async Task Desktop(PackageRequest request) } /// The files every package carries next to the game: notices, README.txt and build-info.json. - public static void WriteCommon(string top, GameProject game, PackageTarget t, Dictionary info) + public static void WriteCommon(string top, GameProject game, PackageTarget t, DotnetRuntime runtime, Dictionary info) { - CopyNotices(top, game, t.Rid.Split(';')[0]); + CopyNotices(top, game, runtime); File.WriteAllText(Path.Combine(top, "README.txt"), Readme(game, t, info)); File.WriteAllText(Path.Combine(top, "build-info.json"), JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true }) + "\n"); } /// build-info.json: what a package is, so a player or a check can tell which game, version and build it holds. - public static Dictionary BuildInfo(PackageRequest request, string signing) + /// The version of the .NET runtime the package carries (). + public static Dictionary BuildInfo(PackageRequest request, string signing, string dotnet) { var game = request.Game; var info = new Dictionary { ["schema"] = 1, ["game"] = game.Id, ["title"] = game.Title, ["gameVersion"] = game.Version, ["engineVersion"] = game.EngineVersion, ["target"] = request.Target.Id, ["runtime"] = request.Target.Rid, ["key"] = request.Key, ["commit"] = Commit(game.Directory), - ["dotnet"] = game.DotnetVersion, ["signing"] = signing, ["tool"] = Tool.Version, + ["dotnet"] = dotnet, ["signing"] = signing, ["tool"] = Tool.Version, }; if (game.ApplicationId is { } id) info["applicationId"] = id; return info; @@ -144,20 +162,40 @@ public static string Commit(string dir) catch (CliException) { return "unknown"; } } - /// The .NET runtime pack of a runtime identifier in the NuGet cache: its licence and notices travel with the package. - public static string RuntimePack(string rid, string version) + /// + /// The Microsoft.NETCore.App runtime pack a publish of for uses, as + /// MSBuild itself resolves it (its ResolvedRuntimePack item): the latest patch of the project's target framework that + /// the running SDK knows, in the NuGet cache or, for a workload's pack, in the SDK's packs folder. Never the SDK's own + /// runtime: SDK 11 publishes a net10.0 game with a 10.0 runtime pack. The project must be restored for + /// ; are the publish's own (-p:Name=Value). + /// + public static DotnetRuntime RuntimePack(string project, string rid, IEnumerable? properties = null) + { + string json = Tool.Run(Tool.Dotnet, ["msbuild", project, "-nologo", "-p:Configuration=Release", $"-p:RuntimeIdentifier={rid}", "-p:SelfContained=true", + .. properties ?? [], "-t:ResolveFrameworkReferences", "-getItem:ResolvedRuntimePack"], Path.GetDirectoryName(project), "resolving the .NET runtime pack"); + return ParseRuntimePack(json) ?? throw new CliException($"MSBuild resolved no .NET runtime pack for {Path.GetFileName(project)} ({rid})."); + } + + /// The Microsoft.NETCore.App pack in the output of dotnet msbuild -getItem:ResolvedRuntimePack, or null. + public static DotnetRuntime? ParseRuntimePack(string json) { - string cache = Environment.GetEnvironmentVariable("NUGET_PACKAGES") is { Length: > 0 } p ? p - : Tool.Run(Tool.Dotnet, ["nuget", "locals", "global-packages", "--list"], what: "dotnet nuget locals").Split(':', 2)[1].Trim(); - string dir = Path.Combine(cache, $"microsoft.netcore.app.runtime.{rid}", version); - foreach (var f in new[] { "LICENSE.TXT", "THIRD-PARTY-NOTICES.TXT" }) - if (!File.Exists(Path.Combine(dir, f))) throw new CliException($"{Path.Combine(dir, f)} is missing; the publish step should have restored the .NET runtime pack there."); - return dir; + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("Items", out var items) || !items.TryGetProperty("ResolvedRuntimePack", out var packs)) return null; + foreach (var pack in packs.EnumerateArray()) + { + string Metadata(string name) => pack.TryGetProperty(name, out var v) ? v.GetString() ?? "" : ""; + if (Metadata("FrameworkName") == "Microsoft.NETCore.App" && Metadata("PackageDirectory") is { Length: > 0 } dir) + return new DotnetRuntime(dir, Metadata("NuGetPackageVersion")); + } + return null; + } + catch (JsonException e) { throw new CliException($"could not read the .NET runtime pack from MSBuild: it printed something unexpected ({e.Message})."); } } - private static void CopyNotices(string top, GameProject game, string rid) + private static void CopyNotices(string top, GameProject game, DotnetRuntime runtime) { - var pack = RuntimePack(rid, game.DotnetVersion); Directory.CreateDirectory(Path.Combine(top, "licenses")); if (game.LicenseFile is { } licence) { @@ -168,8 +206,8 @@ private static void CopyNotices(string top, GameProject game, string rid) File.Copy(Path.Combine(Tool.Home, "Notices", "microengine-LICENSE.txt"), Path.Combine(top, "licenses", "microengine-LICENSE.txt")); // SDL's and raylib's licences, for each of their native libraries the package holds (on macOS inside the bundle). foreach (string engine in EngineNotices.ForFiles(Walk(top))) File.Copy(EngineNotices.PathOf(engine), Path.Combine(top, "licenses", engine)); - File.Copy(Path.Combine(pack, "LICENSE.TXT"), Path.Combine(top, "licenses", "dotnet-LICENSE.txt")); - File.Copy(Path.Combine(pack, "THIRD-PARTY-NOTICES.TXT"), Path.Combine(top, "licenses", "dotnet-THIRD-PARTY-NOTICES.txt")); + File.Copy(runtime.Licence, Path.Combine(top, "licenses", "dotnet-LICENSE.txt")); + File.Copy(runtime.Notices, Path.Combine(top, "licenses", "dotnet-THIRD-PARTY-NOTICES.txt")); } private static string Readme(GameProject game, PackageTarget t, Dictionary info) diff --git a/src/MicroEngine.Cli/Program.cs b/src/MicroEngine.Cli/Program.cs index b213eefe..0dc1fae3 100644 --- a/src/MicroEngine.Cli/Program.cs +++ b/src/MicroEngine.Cli/Program.cs @@ -203,7 +203,7 @@ private static int Info(Options o) Entry point {game.DescribeEntryPoint()} Content {game.ContentDir ?? "(no Content folder)"} Licence {game.LicenseFile ?? "(none)"} - Engine MicroEngine {game.EngineVersion}, .NET {game.DotnetVersion} + Engine MicroEngine {game.EngineVersion}, {game.TargetFramework} """); if (game.ApplicationId is { } id && !GameProject.IsValidId(id)) Console.WriteLine($"Problem ApplicationId '{id}' is not valid."); return iconProblem is null ? 0 : 1; diff --git a/tests/MicroEngine.Cli.Tests/NoticesTests.cs b/tests/MicroEngine.Cli.Tests/NoticesTests.cs index c128ef67..539a0c79 100644 --- a/tests/MicroEngine.Cli.Tests/NoticesTests.cs +++ b/tests/MicroEngine.Cli.Tests/NoticesTests.cs @@ -41,24 +41,40 @@ public void A_phone_package_carries_the_SDL_licence(string kind) } [TestMethod] - public void The_runtime_licences_come_from_the_workload_pack_that_is_installed_even_when_the_SDK_is_newer() + public void The_runtime_licences_come_from_the_runtime_pack_MSBuild_resolved_not_from_the_SDK_version() { - // An SDK newer than the latest workload set (10.0.404 with the 10.403 set) bundles runtime 10.0.15, but the - // workload installs its runtime packs at 10.0.14. - string root = Directory.CreateTempSubdirectory("dotnet-root-").FullName; - try - { - string packs = Path.Combine(root, "packs", "Microsoft.NETCore.App.Runtime.Mono.browser-wasm"); - foreach (var version in new[] { "10.0.9", "10.0.14" }) + // What SDK 11.0.100 (which bundles runtime 11.0.0) prints for a net10.0 Android host: the pack it uses is 10.0.15, + // and other frameworks' packs come first. + const string json = """ { - Directory.CreateDirectory(Path.Combine(packs, version)); - File.WriteAllText(Path.Combine(packs, version, "LICENSE.TXT"), version); + "Items": { + "ResolvedRuntimePack": [ + { "Identity": "Microsoft.Android.Runtime.36.android", "FrameworkName": "Microsoft.Android", "NuGetPackageVersion": "36.1.118", "PackageDirectory": "/dotnet/packs/Microsoft.Android.Runtime.36.android/36.1.118" }, + { "Identity": "Microsoft.NETCore.App.Runtime.Mono.android-arm64", "FrameworkName": "Microsoft.NETCore.App", "NuGetPackageVersion": "10.0.15", "PackageDirectory": "/dotnet/packs/Microsoft.NETCore.App.Runtime.Mono.android-arm64/10.0.15" } + ] + } } - Assert.AreEqual(Path.Combine(packs, "10.0.14"), Hosts.WorkloadPack(root, "Microsoft.NETCore.App.Runtime.Mono.browser-wasm", "10.0.15"), "the newest pack installed"); - Assert.AreEqual(Path.Combine(packs, "10.0.9"), Hosts.WorkloadPack(root, "Microsoft.NETCore.App.Runtime.Mono.browser-wasm", "10.0.9"), "the SDK's own runtime version when it is installed"); - Assert.AreEqual(Path.Combine(root, "packs", "Missing", "10.0.15"), Hosts.WorkloadPack(root, "Missing", "10.0.15"), "no pack at all: the expected path, for the error message"); + """; + Assert.AreEqual(new DotnetRuntime("/dotnet/packs/Microsoft.NETCore.App.Runtime.Mono.android-arm64/10.0.15", "10.0.15"), Packaging.ParseRuntimePack(json)); + Assert.IsNull(Packaging.ParseRuntimePack("""{ "Items": { "ResolvedRuntimePack": [] } }"""), "no runtime pack resolved (a project restored for another runtime)"); + Assert.IsNull(Packaging.ParseRuntimePack("""{ "Items": {} }""")); + Assert.ThrowsExactly(() => Packaging.ParseRuntimePack("warning: not JSON")); + } + + [TestMethod] + public void A_runtime_pack_without_its_licence_files_is_an_error_that_names_the_folder() + { + string dir = Directory.CreateTempSubdirectory("runtime-pack-").FullName; + try + { + var pack = new DotnetRuntime(dir, "10.0.15"); + File.WriteAllText(Path.Combine(dir, "LICENSE.TXT"), "MIT"); + Assert.AreEqual(Path.Combine(dir, "LICENSE.TXT"), pack.Licence); + var error = Assert.ThrowsExactly(() => pack.Notices); + StringAssert.Contains(error.Message, Path.Combine(dir, "THIRD-PARTY-NOTICES.TXT")); + StringAssert.Contains(error.Message, "10.0.15"); } - finally { Directory.Delete(root, recursive: true); } + finally { Directory.Delete(dir, recursive: true); } } [TestMethod] diff --git a/tests/MicroEngine.Cli.Tests/WorkloadTests.cs b/tests/MicroEngine.Cli.Tests/WorkloadTests.cs new file mode 100644 index 00000000..5881ec3e --- /dev/null +++ b/tests/MicroEngine.Cli.Tests/WorkloadTests.cs @@ -0,0 +1,16 @@ +namespace MicroEngine.Cli.Tests; + +/// Which workload the tool names for browser packages, given the .NET SDK that builds the game. +[TestClass] +public sealed class WorkloadTests +{ + [TestMethod] + [DataRow("10.0.404", "wasm-tools")] + [DataRow("11.0.100", "wasm-tools-net10")] + [DataRow("12.0.100-preview.1", "wasm-tools-net10")] + public void A_newer_SDK_builds_a_net10_game_for_browsers_with_the_net10_workload(string sdk, string workload) => + Assert.AreEqual(workload, Doctor.WebWorkload(Doctor.Major(sdk))); + + [TestMethod] + public void A_version_that_is_not_one_has_major_zero() => Assert.AreEqual(0, Doctor.Major("")); +} From 6d897e3967de23e5263c994a24941e0d29c950d8 Mon Sep 17 00:00:00 2001 From: Kevin Martinez Date: Thu, 12 Nov 2026 15:35:15 -0500 Subject: [PATCH 2/3] fix(cli): point an incomplete workload runtime pack to dotnet workload repair The external check expects a failed web package only when no browser runtime pack is on disk, since a newer SDK accepts another SDK's matching pack as its workload. The embedding page names wasm-tools-net10 for SDK 11. --- docs/examples/embedding.md | 3 ++- sdk/tests/external.mjs | 6 +++++- src/MicroEngine.Cli/Packaging.cs | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/examples/embedding.md b/docs/examples/embedding.md index fc3d62be..0dc6753b 100644 --- a/docs/examples/embedding.md +++ b/docs/examples/embedding.md @@ -12,7 +12,8 @@ dotnet workload install wasm-tools microengine package web ``` -The first command installs .NET's WebAssembly build tools, once per machine. `package web` writes +The first command installs .NET's WebAssembly build tools, once per machine (with a .NET 11 SDK, `wasm-tools-net10`: +the tools for the game's .NET 10; `microengine doctor web` names the one your SDK needs). `package web` writes `dist/--web/` and a ZIP of the same files. In browsers the game runs on the SDL platform compiled to WebAssembly (SDL for the browser comes inside the `MicroEngine.Sdl` package), so every game that references `MicroEngine.Sdl` or `MicroEngine.Desktop` can be packaged, with no code change. diff --git a/sdk/tests/external.mjs b/sdk/tests/external.mjs index c840ce8a..8c470716 100644 --- a/sdk/tests/external.mjs +++ b/sdk/tests/external.mjs @@ -187,7 +187,11 @@ try { } if (process.platform !== 'win32') expectFailure('Windows package on another system', null, ['package', 'windows'], /built on Windows/); const workloads = run('dotnet', ['workload', 'list']).out; - if (!hasWorkload(workloads, webWorkload)) expectFailure(`web package without the ${webWorkload} workload`, null, ['package', 'web'], new RegExp(webWorkload)); + // A newer SDK also accepts the older framework's workload as installed when another SDK's wasm-tools left the same + // runtime pack on disk (packs are shared), so the failure is certain only without any browser runtime pack. + const dotnetRoot = dotnetOutside(['--list-sdks']).match(/\[(.+)\]/)?.[1]; + const wasmPacks = dotnetRoot && path.join(path.dirname(dotnetRoot), 'packs', 'Microsoft.NETCore.App.Runtime.Mono.browser-wasm'); + if (!hasWorkload(workloads, webWorkload) && !(wasmPacks && fs.existsSync(wasmPacks))) expectFailure(`web package without the ${webWorkload} workload`, null, ['package', 'web'], new RegExp(webWorkload)); r = run(me, ['doctor', 'web'], { cwd: game }); check(`microengine doctor web names the ${webWorkload} workload for .NET SDK ${sdkVersion}`, new RegExp(`${webWorkload} workload: (installed|dotnet workload install ${webWorkload})`).test(r.out), tail(r.out)); // Only a case-sensitive file system can hold both names; the build refuses them so every system loads the same file. diff --git a/src/MicroEngine.Cli/Packaging.cs b/src/MicroEngine.Cli/Packaging.cs index 45218968..66aedbca 100644 --- a/src/MicroEngine.Cli/Packaging.cs +++ b/src/MicroEngine.Cli/Packaging.cs @@ -54,7 +54,9 @@ private string Required(string name) { string file = Path.Combine(Directory, name); return File.Exists(file) ? file - : throw new CliException($"{file} is missing: the .NET {Version} runtime pack in {Directory} is incomplete; delete that folder and package again to restore it."); + : throw new CliException($"{file} is missing: the .NET {Version} runtime pack in {Directory} is incomplete; " + + (Directory.Replace('\\', '/').Contains("/packs/", StringComparison.OrdinalIgnoreCase) + ? "it belongs to a .NET workload: run 'dotnet workload repair'." : "delete that folder and package again to restore it.")); } } From 3ba872a01c3cc5aac9815d83476d7712661f9318 Mon Sep 17 00:00:00 2001 From: Kevin Martinez Date: Thu, 12 Nov 2026 19:53:59 -0500 Subject: [PATCH 3/3] fix(ci): run the Firefox browser suites under a window manager Firefox 160 (ubuntu-24.04 runner image 20261103.629) fails the check "fullscreen button and Alt+Enter" with {"on":false,"w":960}. Reproduced in a container with Firefox 159.0 and 160.0 from ftp.mozilla.org under xvfb-run and puppeteer-core 25.13.0 (WebDriver BiDi): the button's click carries user activation (navigator.userActivation.isActive is true) and calls requestFullscreen(), but on Firefox 160 on a bare Xvfb the promise never settles and neither fullscreenchange nor fullscreenerror fires, even after 6 s. Firefox 159 completes the same request on the same display. With a window manager (Openbox) running on the display, both versions enter fullscreen; headless Firefox 160 does too. So Firefox 160 on X11 waits for the window manager to make the window fullscreen, and xvfb-run starts none. The page's fullscreen button is correct: a real desktop always has a window manager. The browser engine job installs openbox and the Firefox step starts it inside xvfb-run before the suites. Chrome's steps are unchanged. --- .github/workflows/checks.yml | 7 +++++-- docs/ci.md | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 10fcacd0..54909553 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -528,7 +528,7 @@ jobs: - name: Virtual display and sound server run: | sudo apt-get update -q - sudo apt-get install -y --no-install-recommends xvfb xauth libgl1 libglx-mesa0 libgl1-mesa-dri pulseaudio pulseaudio-utils + sudo apt-get install -y --no-install-recommends xvfb xauth libgl1 libglx-mesa0 libgl1-mesa-dri pulseaudio pulseaudio-utils openbox export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNNER_TEMP/xdg}" mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR" echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV" @@ -560,10 +560,13 @@ jobs: exit "$status" - name: Firefox suites if: ${{ !cancelled() }} + # Under a window manager, as on a real desktop: from Firefox 160 on, a fullscreen request on X11 waits for the + # window manager to make the window fullscreen, so on a bare Xvfb it never completes (Firefox 159 completed it). run: | status=0 firefox --version - xvfb-run -a -s "-screen 0 1920x1080x24" node web/tests/run.mjs platform scripts repeat keyboard framerate audio gamepad \ + xvfb-run -a -s "-screen 0 1920x1080x24" bash -c 'openbox & sleep 1; exec node web/tests/run.mjs "$@"' firefox-suites \ + platform scripts repeat keyboard framerate audio gamepad \ --browsers firefox --games "$GAME" --headed --out web-tests | tee web-tests-firefox.log || status=$? grep -E '^(==|FAIL|[0-9]+ passed)' web-tests-firefox.log | sed 's/^/- /' >> "$GITHUB_STEP_SUMMARY" || true exit "$status" diff --git a/docs/ci.md b/docs/ci.md index 3e2bcb87..4c90ca9e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -507,8 +507,9 @@ The macOS and iOS runtimes build only when macOS runners are on (daily CI passes ### Browser tests `web/tests/run.mjs` plays development packages (`web/build.sh --dev`, SDL 3 in WebAssembly) in real browsers. In daily -CI (the **Browser engine** jobs, Chrome and Firefox on a virtual X server with a virtual sound server, one job per game -the change reaches) and in validate (all four games), it runs these suites, and the job fails when any of their checks +CI (the **Browser engine** jobs, Chrome and Firefox on a virtual X server with a virtual sound server, Firefox also +under a window manager (Openbox), without which Firefox 160 and later never complete a fullscreen request; one job per +game the change reaches) and in validate (all four games), it runs these suites, and the job fails when any of their checks fails: | Suite | What it shows |