From 0d664fa4c0fceb889409f5cb1d22a2f73ef1c543 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 10:45:28 +0000 Subject: [PATCH 1/4] ci(web): single-source toolchain versions, patch-level drift guard, 4.7.1 bump Closes three gaps in the experimental C#/WASM export pipeline and adds a plan for getting it onto officially supported foundations. Fixes: - Production and preview could build different toolchains. The editor tag and template version were duplicated in four places (composite action defaults, workflow_dispatch defaults, and inline fallbacks in the production job) while the preview job passed nothing and inherited the action defaults. Updating one and not the others meant /preview validated a toolchain production never used. All versions now come from .github/web-toolchain.env; action inputs default to empty and act purely as deliberate overrides. - The drift guard compared only major.minor, so a 4.7.0-vs-4.7.1 mismatch passed silently -- the most likely kind of bump. It now normalises all three spellings of a version (4.7.1 / 4.7.1.stable.mono / 4.7.1-stable), requires exact agreement including the editor tag, and handles Godot's convention that x.y releases carry no patch component. It also verifies a checksum is pinned for the target asset before downloading a 165 MB editor rather than after. - Checksum verification matched the first zip found in the cache directory; it now requires the expected filename, so a restored cache cannot substitute a differently-named editor. Cache key includes the fork repo as well as the tag. Toolchain bump to 4.7.1-stable (upstream 4.7.1 is a stability-only release with no known incompatibilities with 4.7). Superseded checksums are retained so a rollback needs no checksum work. Docs: - docs/WEB_EXPORT_ROADMAP.md (new): upstream status with evidence, definition of done, four-phase migration plan, risk register, quarterly monitoring routine and rollback procedure. Records that LibGodot Core (#110863) merged and shipped in Godot 4.6, that the approach pivoted away from the stalled #106125 to LibGodot, and that #121502 and #118976 remain open at milestone 4.x -- so official C# web export is still unscheduled. - docs/WEB_EXPORT.md: corrected the DNS section (maze.ryankelly.dev resolves), softened the fork-abandonment risk (the fork matched upstream 4.7.1 within two days), and repointed the version matrix at the new single source of truth. - AGENTS.md: added the web-only failure modes (invariant globalization, missing crypto BCL APIs, no GDExtension), which pass desktop CI and break only in the browser. Not verified here: no .NET SDK in this environment, so the desktop build against Godot.NET.Sdk 4.7.1 is unbuilt. The version-resolution and drift-guard logic was executed against all drift permutations, and both editor checksums were computed from the published assets (the 4.7-stable hash reproduces the existing pin). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwhKDz2xEVEtYECxT7q5F3 --- .github/actions/export-web/action.yml | 144 +++++++++++++++--- .github/editor-checksums.txt | 9 +- .github/web-toolchain.env | 27 ++++ .github/workflows/web-export.yml | 23 ++- AGENTS.md | 26 ++++ ProceduralGeneration3DMazes.csproj | 7 +- docs/WEB_EXPORT.md | 147 +++++++++++++------ docs/WEB_EXPORT_ROADMAP.md | 203 ++++++++++++++++++++++++++ 8 files changed, 504 insertions(+), 82 deletions(-) create mode 100644 .github/web-toolchain.env create mode 100644 docs/WEB_EXPORT_ROADMAP.md diff --git a/.github/actions/export-web/action.yml b/.github/actions/export-web/action.yml index af113a9..b3d03e8 100644 --- a/.github/actions/export-web/action.yml +++ b/.github/actions/export-web/action.yml @@ -4,18 +4,23 @@ description: > Windows editor build. Must run on a windows-latest runner. Produces build/web. inputs: + # These three intentionally default to EMPTY. When empty they are resolved from + # .github/web-toolchain.env — the single source of truth — so every caller + # (production, /preview, manual dispatch) uses identical versions unless a human + # deliberately overrides one. Do not reintroduce literal version defaults here or + # in workflow files: duplicated defaults are how production and preview drift apart. fork_repo: - description: "Repo hosting the patched editor release (override to point at a mirror or, eventually, official support)" + description: "Override the repo hosting the patched editor release (blank = value from .github/web-toolchain.env)" required: false - default: "ComplexRobot/godot-dotnet-web-export" + default: "" godot_fork_tag: - description: "Patched-editor release tag (editor + web templates)" + description: "Override the patched-editor release tag (blank = value from .github/web-toolchain.env)" required: false - default: "4.7-stable" + default: "" template_version: - description: "Export-template version folder name (must match the editor build)" + description: "Override the export-template folder name (blank = value from .github/web-toolchain.env)" required: false - default: "4.7.stable.mono" + default: "" github_token: description: "Token for downloading the fork release via gh" required: true @@ -24,25 +29,107 @@ outputs: web_dir: description: "Path to the exported web output" value: "build/web" + fork_tag: + description: "The patched-editor tag actually used for this export" + value: ${{ steps.toolchain.outputs.fork_tag }} + template_version: + description: "The export-template version actually used for this export" + value: ${{ steps.toolchain.outputs.template_version }} runs: using: composite steps: + - name: Resolve toolchain versions + id: toolchain + shell: pwsh + env: + IN_FORK_REPO: ${{ inputs.fork_repo }} + IN_FORK_TAG: ${{ inputs.godot_fork_tag }} + IN_TEMPLATE_VERSION: ${{ inputs.template_version }} + run: | + # Read the shared toolchain file, then let non-empty inputs override it. + $path = ".github/web-toolchain.env" + if (-not (Test-Path $path)) { throw "Missing $path — the web export toolchain versions live there. See docs/WEB_EXPORT.md." } + $cfg = @{} + foreach ($line in Get-Content $path) { + if ($line -match '^\s*#' -or $line -notmatch '=') { continue } + $k, $v = $line -split '=', 2 + $cfg[$k.Trim()] = $v.Trim() + } + + function Resolve-Value($inputValue, $key) { + if (-not [string]::IsNullOrWhiteSpace($inputValue)) { + Write-Host "$key = $inputValue (overridden by workflow input)" + return $inputValue + } + if (-not $cfg.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($cfg[$key])) { + throw "$key is not set in .github/web-toolchain.env and no override was supplied." + } + Write-Host "$key = $($cfg[$key]) (from .github/web-toolchain.env)" + return $cfg[$key] + } + + $forkRepo = Resolve-Value $env:IN_FORK_REPO 'GODOT_FORK_REPO' + $forkTag = Resolve-Value $env:IN_FORK_TAG 'GODOT_FORK_TAG' + $template = Resolve-Value $env:IN_TEMPLATE_VERSION 'GODOT_TEMPLATE_VERSION' + + "fork_repo=$forkRepo" >> $env:GITHUB_OUTPUT + "fork_tag=$forkTag" >> $env:GITHUB_OUTPUT + "template_version=$template" >> $env:GITHUB_OUTPUT + "asset=Godot_v${forkTag}_mono_web_export_win64.zip" >> $env:GITHUB_OUTPUT + - name: Guard against version drift shell: pwsh env: - TEMPLATE_VERSION: ${{ inputs.template_version }} + TEMPLATE_VERSION: ${{ steps.toolchain.outputs.template_version }} + FORK_TAG: ${{ steps.toolchain.outputs.fork_tag }} + ASSET: ${{ steps.toolchain.outputs.asset }} run: | - # The Godot.NET.Sdk version (csproj), the fork tag, and the export-template - # version must share a major.minor, or the export fails cryptically. Catch it early. + # The Godot.NET.Sdk version (csproj), the patched-editor tag, and the + # export-template folder must agree at PATCH level — comparing only + # major.minor lets a 4.7.0-vs-4.7.1 mismatch through, which is exactly the + # class of bump that happens most often. Catch it in seconds, before we + # download a 165 MB editor. + # + # Normalise the three spellings of the same version to a bare x.y[.z]: + # csproj "4.7.1" -> 4.7.1 ("4.7.0" -> 4.7, see below) + # template "4.7.1.stable.mono" -> 4.7.1 + # fork tag "4.7.1-stable" -> 4.7.1 + # Godot names x.y releases without a patch component, so an SDK version of + # "4.7.0" corresponds to template "4.7.stable.mono": strip a trailing ".0". + function Get-BareVersion([string]$s) { + return ($s -replace '[-.](stable|beta|rc|dev)[-.0-9]*.*$', '') + } + $csproj = Get-Content ProceduralGeneration3DMazes.csproj -Raw - if ($csproj -notmatch 'Godot\.NET\.Sdk/(\d+)\.(\d+)\.') { throw "Could not parse Godot.NET.Sdk version from csproj." } - $sdkMM = "$($Matches[1]).$($Matches[2])" - $tplMM = (($env:TEMPLATE_VERSION -split '\.')[0..1]) -join '.' - if ($sdkMM -ne $tplMM) { - throw "Version drift: Godot.NET.Sdk is $sdkMM but template_version is '$env:TEMPLATE_VERSION' ($tplMM). Align the csproj SDK version, the fork tag, and the template version — see docs/WEB_EXPORT.md." + if ($csproj -notmatch 'Godot\.NET\.Sdk/(\d+\.\d+\.\d+)') { throw "Could not parse Godot.NET.Sdk version from csproj." } + $sdkVersion = $Matches[1] -replace '\.0$', '' + $tplVersion = Get-BareVersion $env:TEMPLATE_VERSION + $tagVersion = Get-BareVersion $env:FORK_TAG + + $mismatch = ($sdkVersion -ne $tplVersion) -or ($sdkVersion -ne $tagVersion) + if ($mismatch) { + # Built as an array rather than a here-string: a PowerShell here-string needs + # its closing "@ at column 0, which would terminate this YAML block scalar. + throw (@( + "Version drift - these must all describe the same Godot version:", + " Godot.NET.Sdk (csproj) -> $sdkVersion", + " template_version -> $tplVersion (raw: $env:TEMPLATE_VERSION)", + " godot_fork_tag -> $tagVersion (raw: $env:FORK_TAG)", + "Align them in ProceduralGeneration3DMazes.csproj and .github/web-toolchain.env.", + "See docs/WEB_EXPORT.md -> 'Updating the pinned editor'." + ) -join "`n") + } + + # Fail fast if the editor we're about to fetch has no pinned checksum, rather + # than after spending a runner-minute downloading it. + $pinned = Get-Content .github/editor-checksums.txt | + Where-Object { $_ -notmatch '^\s*#' -and $_ -match [regex]::Escape($env:ASSET) } + if (-not $pinned) { + throw "No pinned SHA-256 for '$env:ASSET' in .github/editor-checksums.txt — refusing to run an unverified binary. See docs/WEB_EXPORT.md -> 'Updating the pinned editor'." } - Write-Host "Version check OK: Godot.NET.Sdk $sdkMM matches template $env:TEMPLATE_VERSION" + + Write-Host "Version check OK: Godot.NET.Sdk, templates and editor tag all agree on $sdkVersion (checksum pinned for $env:ASSET)." - name: Set up .NET 9 SDK uses: actions/setup-dotnet@v4 @@ -58,27 +145,36 @@ runs: uses: actions/cache@v4 with: path: ${{ runner.temp }}/godot-zip - key: godot-editor-${{ inputs.godot_fork_tag }} + key: godot-editor-${{ steps.toolchain.outputs.fork_repo }}-${{ steps.toolchain.outputs.fork_tag }} - name: Download patched Godot editor (web-export fork) if: steps.editorcache.outputs.cache-hit != 'true' shell: pwsh env: GH_TOKEN: ${{ inputs.github_token }} - FORK_REPO: ${{ inputs.fork_repo }} - FORK_TAG: ${{ inputs.godot_fork_tag }} + FORK_REPO: ${{ steps.toolchain.outputs.fork_repo }} + FORK_TAG: ${{ steps.toolchain.outputs.fork_tag }} + ASSET: ${{ steps.toolchain.outputs.asset }} run: | - $asset = "Godot_v${env:FORK_TAG}_mono_web_export_win64.zip" New-Item -ItemType Directory -Force -Path "${env:RUNNER_TEMP}/godot-zip" | Out-Null - Write-Host "Downloading $asset from $env:FORK_REPO@$env:FORK_TAG" - gh release download "$env:FORK_TAG" --repo "$env:FORK_REPO" --pattern "$asset" --dir "${env:RUNNER_TEMP}/godot-zip" + Write-Host "Downloading $env:ASSET from $env:FORK_REPO@$env:FORK_TAG" + gh release download "$env:FORK_TAG" --repo "$env:FORK_REPO" --pattern "$env:ASSET" --dir "${env:RUNNER_TEMP}/godot-zip" - name: Verify editor checksum (supply-chain guard) shell: pwsh + env: + ASSET: ${{ steps.toolchain.outputs.asset }} run: | # Runs on both fresh downloads and cache hits — verifies the exact bytes we execute. - $zip = Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -Filter *.zip | Select-Object -First 1 - if (-not $zip) { throw "Editor zip not found (cache or download failed)." } + # Match the expected filename explicitly: a restored cache must not be able to + # substitute a differently-named editor zip for the one we pinned. + $zip = Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -Filter *.zip | + Where-Object { $_.Name -eq $env:ASSET } | Select-Object -First 1 + if (-not $zip) { + Write-Host "Contents of ${env:RUNNER_TEMP}/godot-zip:" + Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $($_.Name)" } + throw "Expected editor zip '$env:ASSET' not found (cache or download failed)." + } $line = Get-Content .github/editor-checksums.txt | Where-Object { $_ -notmatch '^\s*#' -and $_ -match [regex]::Escape($zip.Name) } | Select-Object -First 1 if (-not $line) { throw "No pinned checksum for '$($zip.Name)' in .github/editor-checksums.txt — refusing to run an unverified binary. See docs/WEB_EXPORT.md." } @@ -114,7 +210,7 @@ runs: - name: Install web export templates (self-contained mode) shell: pwsh env: - TEMPLATE_VERSION: ${{ inputs.template_version }} + TEMPLATE_VERSION: ${{ steps.toolchain.outputs.template_version }} run: | $bundle = "${{ steps.locate.outputs.bundle_dir }}" New-Item -ItemType File -Force -Path (Join-Path $bundle "._sc_") | Out-Null diff --git a/.github/editor-checksums.txt b/.github/editor-checksums.txt index 98d25f0..d74b2f8 100644 --- a/.github/editor-checksums.txt +++ b/.github/editor-checksums.txt @@ -3,6 +3,11 @@ # mismatch — a supply-chain guard for the third-party binary we run. # # Format: -# To add/update an entry (e.g. when bumping the fork tag), see -# docs/WEB_EXPORT.md → "Updating the pinned editor". +# +# Entries for superseded tags are kept deliberately: rolling GODOT_FORK_TAG back to +# a previous release (see docs/WEB_EXPORT_ROADMAP.md → "Rollback") must not also +# require restoring a checksum line. +# +# To add an entry when bumping, see docs/WEB_EXPORT.md → "Updating the pinned editor". +ad76e72610187b13e83229e863928c32689b1ba5dda34f5210940d563b89e473 Godot_v4.7.1-stable_mono_web_export_win64.zip b1f1b387dd45c6f3db35b336f58d40d6ea7ea0c8d7597d4fc26b493f4d12347c Godot_v4.7-stable_mono_web_export_win64.zip diff --git a/.github/web-toolchain.env b/.github/web-toolchain.env new file mode 100644 index 0000000..270a9f9 --- /dev/null +++ b/.github/web-toolchain.env @@ -0,0 +1,27 @@ +# Single source of truth for the experimental C#/.NET web-export toolchain. +# +# The web export is version-locked across several pieces (patched editor build, +# export templates, Godot.NET.Sdk, .NET SDK). A mismatch produces a cryptic export +# failure, so these values live here — in ONE place — and every workflow reads them +# via .github/actions/export-web. Do not duplicate them into workflow defaults. +# +# `Godot.NET.Sdk` (ProceduralGeneration3DMazes.csproj) and the .NET SDK version +# (global.json) are the two rows that cannot live here, because those files own them. +# The export action's "version drift" guard cross-checks the csproj against these +# values and fails fast if they disagree. +# +# To bump, see docs/WEB_EXPORT.md → "Updating the pinned editor". +# For the plan to get off this fork entirely, see docs/WEB_EXPORT_ROADMAP.md. + +# Repo publishing the patched editor. Override to point at a mirror (see the +# roadmap's "mirror the editor" mitigation) or, eventually, official support. +GODOT_FORK_REPO=ComplexRobot/godot-dotnet-web-export + +# Patched-editor release tag. Also determines the downloaded asset filename: +# Godot_v_mono_web_export_win64.zip +GODOT_FORK_TAG=4.7.1-stable + +# Export-template folder name. Must match the editor build exactly. +# Note Godot's convention: x.y releases have NO patch component +# ("4.7.stable.mono"), while x.y.z releases do ("4.7.1.stable.mono"). +GODOT_TEMPLATE_VERSION=4.7.1.stable.mono diff --git a/.github/workflows/web-export.yml b/.github/workflows/web-export.yml index 929dfe4..9712791 100644 --- a/.github/workflows/web-export.yml +++ b/.github/workflows/web-export.yml @@ -25,16 +25,21 @@ on: - ".github/workflows/web-export.yml" - ".github/actions/export-web/**" - ".github/editor-checksums.txt" + - ".github/web-toolchain.env" workflow_dispatch: + # Both blank by default: the versions come from .github/web-toolchain.env so that + # dispatch, push-to-main and /preview always agree. Fill one in only to test an + # unpinned editor build ad hoc — note the checksum guard still requires an entry + # in .github/editor-checksums.txt for whatever tag you name. inputs: godot_fork_tag: - description: "Patched-editor release tag" - required: true - default: "4.7-stable" + description: "Override patched-editor release tag (blank = .github/web-toolchain.env)" + required: false + default: "" template_version: - description: "Export-template version folder name" - required: true - default: "4.7.stable.mono" + description: "Override export-template folder name (blank = .github/web-toolchain.env)" + required: false + default: "" issue_comment: types: [created] @@ -63,8 +68,10 @@ jobs: - name: Export web (WASM) uses: ./.github/actions/export-web with: - godot_fork_tag: ${{ github.event.inputs.godot_fork_tag || '4.7-stable' }} - template_version: ${{ github.event.inputs.template_version || '4.7.stable.mono' }} + # Pass dispatch overrides straight through; blank resolves from + # .github/web-toolchain.env, identically to the preview job below. + godot_fork_tag: ${{ github.event.inputs.godot_fork_tag }} + template_version: ${{ github.event.inputs.template_version }} github_token: ${{ github.token }} - name: Deploy to Vercel diff --git a/AGENTS.md b/AGENTS.md index 4448f07..0a7edd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,32 @@ In Godot 2D rendering: 3. Add tests in `tests/` 4. Add UI in `scripts/ui/` and `scenes/` +## Web Export Constraints (read before adding BCL dependencies) + +This project ships a browser build (`maze.ryankelly.dev`) via an **experimental** +C#→WASM export on a community-patched Godot editor. See +[docs/WEB_EXPORT.md](./docs/WEB_EXPORT.md) for how it works and +[docs/WEB_EXPORT_ROADMAP.md](./docs/WEB_EXPORT_ROADMAP.md) for the plan to get onto +official support. + +**The web runtime is a subset of desktop .NET. These fail *only* in the browser — the +desktop build and the whole test suite stay green:** + +| Don't use | Why | +|---|---| +| Culture-sensitive formatting/parsing | `InvariantGlobalization=true` is forced; culture data is trimmed out | +| `System.Security.Cryptography` | Crypto BCL APIs are non-functional in the patched runtime | +| GDExtension / native addons | The .NET runtime is built without position-independent code | + +Prefer `CultureInfo.InvariantCulture` explicitly, and keep `scripts/maze/` free of +platform-specific BCL calls. If you add anything in the table above, **test it on web +explicitly**: comment `/preview` on the PR and check the smoke test result — a green +desktop CI proves nothing about the browser build. + +**Version locking:** the patched editor tag and export templates live in +`.github/web-toolchain.env`, and `Godot.NET.Sdk` in the `.csproj` must match them at +patch level. CI fails fast on drift. Never hardcode these versions into workflow files. + ## Running the Game ```bash diff --git a/ProceduralGeneration3DMazes.csproj b/ProceduralGeneration3DMazes.csproj index 1774f17..edc6e0a 100644 --- a/ProceduralGeneration3DMazes.csproj +++ b/ProceduralGeneration3DMazes.csproj @@ -1,4 +1,9 @@ - + + net9.0 diff --git a/docs/WEB_EXPORT.md b/docs/WEB_EXPORT.md index 443d0cf..2a852ca 100644 --- a/docs/WEB_EXPORT.md +++ b/docs/WEB_EXPORT.md @@ -5,16 +5,28 @@ on a static site (e.g. Vercel). **This path is experimental** and relies on a community-patched Godot build, because upstream Godot cannot yet export C#/.NET projects to the web. +> 📍 **This document = how it works today.** For the plan to get onto officially +> supported foundations — upstream status, exit criteria, staged migration, monitoring +> cadence and rollback — see **[WEB_EXPORT_ROADMAP.md](./WEB_EXPORT_ROADMAP.md)**. + ## Why this is non-trivial Godot's official HTML5/WebAssembly export supports **GDScript only**. The .NET -(C#) web export is still unmerged upstream: +(C#) web export is still unmerged upstream — .NET's WASM build expects to be the main +module and doesn't support dynamic linking, so Godot can't load it. + +Status as of 2026-07-25 (see the [roadmap](./WEB_EXPORT_ROADMAP.md#1-where-things-actually-stand) +for the full picture and the dependency chain): -- Tracking issue: -- Implementation PRs: [raulsntos #106125](https://github.com/godotengine/godot/pull/106125) - (the .NET-web prototype), [static LibGodot #118976](https://github.com/godotengine/godot/pull/118976) (still open, "4.x") +- Tracking issue [#70796](https://github.com/godotengine/godot/issues/70796) — still open. +- [#118976](https://github.com/godotengine/godot/pull/118976) (static LibGodot .NET web + export) — draft, milestone `4.x`. **This is the PR that ends the hack.** +- [#121502](https://github.com/godotengine/godot/pull/121502) — LibGodot on web, opened + 2026-07-17. Core-level prerequisite, not C#-specific. +- [#106125](https://github.com/godotengine/godot/pull/106125) — the prototype this + fork is built from. Draft, stalled on .NET 10, effectively superseded. -Until that lands in an official release, exporting requires a **patched editor**: +Until it lands in an official release, exporting requires a **patched editor**: [ComplexRobot/godot-dotnet-web-export](https://github.com/ComplexRobot/godot-dotnet-web-export) — latest stable Godot with the raulsntos PR merged in. Its prebuilt binaries are **Windows-only**, which shapes the build strategy below. @@ -25,23 +37,29 @@ Because the patched editor only ships for Windows, the export runs in CI on a `windows-latest` runner rather than locally on macOS. See [`.github/workflows/web-export.yml`](../.github/workflows/web-export.yml). -Trigger it manually from the Actions tab (**workflow_dispatch**). Inputs: +Trigger it manually from the Actions tab (**workflow_dispatch**). Both inputs are +**blank by default** and resolve from [`.github/web-toolchain.env`](../.github/web-toolchain.env); +fill one in only to test an unpinned editor build ad hoc. | Input | Default | Meaning | |-------|---------|---------| -| `godot_fork_tag` | `4.7-stable` | Release tag of the patched editor to download | -| `template_version` | `4.7.stable.mono` | Export-template folder name (must match the editor build) | +| `godot_fork_tag` | *(blank → toolchain file)* | Release tag of the patched editor to download | +| `template_version` | *(blank → toolchain file)* | Export-template folder name (must match the editor build) | The job: -1. Installs .NET 9 SDK + the `wasm-tools` workload. -2. Downloads the patched editor zip from the fork release. -3. Installs the bundled `web_release.zip` / `web_debug.zip` templates in +1. Resolves the toolchain versions and **fails fast** on version drift or a missing + checksum pin — before downloading a 165 MB editor. +2. Installs .NET 9 SDK + the `wasm-tools` workload. +3. Downloads the patched editor zip from the fork release. +4. Verifies the zip against its **pinned SHA-256**, matching the expected filename + exactly (a restored cache can't substitute a different editor). +5. Installs the bundled `web_release.zip` / `web_debug.zip` templates in **self-contained mode** (next to the editor, via a `._sc_` marker — no AppData). -4. Registers the bundled local NuGet source if present. -5. `dotnet build -c ExportRelease /p:GodotTargetPlatform=web`. -6. Headless import + `godot --headless --export-release "Web" build/web/index.html`. -7. Uploads `build/web/**` (plus `export.log` / `import.log`) as the +6. Registers the bundled local NuGet source if present. +7. `dotnet build -c ExportRelease /p:GodotTargetPlatform=web`. +8. Headless import + `godot --headless --export-release "Web" build/web/index.html`. +9. Uploads `build/web/**` (plus `export.log` / `import.log`) as the **`maze-web-export`** artifact. Download the artifact to test the build, then host it (below). @@ -50,7 +68,10 @@ Download the artifact to test the build, then host it (below). > build, and the CI export job runs green: it produces a real C#→WASM build > (~54 MB `index.wasm` with the .NET runtime + ~42 MB `index.pck`), confirmed by the > wasm32 `.dotnet-publish-manifest` in `export.log`. Desktop build + full test suite -> (421 passing) remain green on Godot.NET.Sdk 4.7.0 / net9.0. +> (421 passing) remain green on net9.0. +> +> Now pinned to **4.7.1** (upstream 4.7.1 is a 78-fix stability release with no known +> incompatibilities with 4.7). Verify with a `/preview` run before relying on it. ## Project-side changes already made @@ -137,16 +158,19 @@ gh secret set VERCEL_PROJECT_ID --repo rtkelly13/ProceduralGeneration3DMazes --b ### DNS for `maze.ryankelly.dev` -`ryankelly.dev` uses **external DNS** (not Vercel nameservers), so the subdomain -record must be added at the registrar — Vercel can't create it. Add: +✅ **Done — resolving as of 2026-07-25.** `maze.ryankelly.dev` is a CNAME to +`cname.vercel-dns.com`, which resolves to Vercel's anycast addresses +(`76.76.21.x` / `66.33.60.x`). SSL provisions automatically once DNS resolves, so +nothing further is needed here. + +Recorded for future reference: `ryankelly.dev` uses **external DNS** (not Vercel +nameservers), so subdomain records must be added at the registrar — Vercel can't +create them. The record is: ``` maze CNAME cname.vercel-dns.com ``` -Until then the alias is attached but won't resolve; the `*.vercel.app` URL works -immediately. SSL for the custom domain provisions automatically once DNS resolves. - ### Post-deploy smoke test Both the production and preview deploys are followed by a Playwright smoke test @@ -164,31 +188,50 @@ A green deploy with a broken runtime therefore fails CI instead of silently ship ## Version matrix (keep these in lockstep) -Three things are version-locked; a mismatch causes a cryptic export failure. The -export action fails fast with a "version drift" error if the first two disagree. +Several pieces are version-locked; a mismatch causes a cryptic export failure. The +editor tag and template version live in **one** file — +[`.github/web-toolchain.env`](../.github/web-toolchain.env) — and every workflow reads +them from there. **Never** duplicate them into workflow or action defaults: that's how +`production` and `/preview` end up building different toolchains. | Piece | Where | Current | |-------|-------|---------| -| `Godot.NET.Sdk` | `ProceduralGeneration3DMazes.csproj` | `4.7.0` | -| Export-template version | workflow `template_version` input | `4.7.stable.mono` | -| Patched-editor release tag | workflow `godot_fork_tag` input | `4.7-stable` | +| Patched-editor release tag | `.github/web-toolchain.env` → `GODOT_FORK_TAG` | `4.7.1-stable` | +| Export-template version | `.github/web-toolchain.env` → `GODOT_TEMPLATE_VERSION` | `4.7.1.stable.mono` | +| Fork repo (or mirror) | `.github/web-toolchain.env` → `GODOT_FORK_REPO` | `ComplexRobot/godot-dotnet-web-export` | +| `Godot.NET.Sdk` | `ProceduralGeneration3DMazes.csproj` | `4.7.1` | | `.NET` SDK | `global.json` | `9.0.315` | | Editor binary SHA-256 | `.github/editor-checksums.txt` | pinned | -When you bump Godot, **all four rows move together.** +When you bump Godot, **the first four rows move together.** The export action's guard +enforces this at **patch level** — it normalises the three spellings of the same +version (`4.7.1`, `4.7.1.stable.mono`, `4.7.1-stable`) and requires exact agreement, +then checks a checksum is pinned for the target asset. All of that runs *before* the +165 MB editor download, so a mistake costs seconds. + +> Godot's naming convention: `x.y` releases have **no** patch component in the template +> name (`4.7.stable.mono`) while `x.y.z` releases do (`4.7.1.stable.mono`). The guard +> handles both — a csproj SDK of `4.7.0` is treated as `4.7`. ### Updating the pinned editor (fork bump) -1. Pick the new fork release tag (must match a `Godot.NET.Sdk` version you can build with). -2. Download its `Godot_v_mono_web_export_win64.zip` and record the hash: +1. Pick the new fork release tag — a **stable** one + ([why](./WEB_EXPORT_ROADMAP.md#phase-1--track-upstream-releases-promptly-routine)) — + and confirm a matching `Godot.NET.Sdk` exists on NuGet. +2. Record the editor hash and **add** (don't replace) the line in + `.github/editor-checksums.txt` — keeping old entries makes rollback a one-line + revert: ```sh - shasum -a 256 Godot_v_mono_web_export_win64.zip + TAG=4.7.1-stable + curl -sL "https://github.com/ComplexRobot/godot-dotnet-web-export/releases/download/$TAG/Godot_v${TAG}_mono_web_export_win64.zip" \ + | shasum -a 256 ``` - Add/replace the line in `.github/editor-checksums.txt`. -3. Bump `Godot.NET.Sdk/` in the `.csproj`, `template_version`, and - `godot_fork_tag` defaults; open the project once in the matching editor to - migrate `project.godot`. + (Piping avoids keeping a 165 MB file around; drop the pipe if you want the zip.) +3. Update `GODOT_FORK_TAG` and `GODOT_TEMPLATE_VERSION` in + `.github/web-toolchain.env`, and `Godot.NET.Sdk/` in the `.csproj`. Open the + project once in the matching editor to migrate `project.godot`. 4. Open a PR and `/preview` it — the smoke test confirms the new toolchain boots. + The drift guard will reject the PR if you missed one of the three version rows. --- @@ -197,22 +240,30 @@ When you bump Godot, **all four rows move together.** The web export rests on **experimental, third-party** foundations. Treat it as something to monitor, not set-and-forget. -- **Official .NET web export is the finish line.** Track godotengine/godot - [#118976](https://github.com/godotengine/godot/pull/118976) (static LibGodot) and - the prototype [#106125](https://github.com/godotengine/godot/pull/106125). **When - official web export ships, migrate off the fork** — repoint the composite action's - `fork_repo` (and drop the custom editor entirely if upstream templates suffice). -- **Single-maintainer fork risk.** The editor comes from one community fork - (`ComplexRobot/godot-dotnet-web-export`). If it goes stale (no build for a Godot - version you need), you're stuck. Mitigations in place: the `fork_repo`/tag are - **inputs** (easy to repoint at a mirror), and the binary is **checksum-pinned**. - Recommended: **mirror the exact editor zip you rely on** to your own release/storage - so a deleted upstream release can't break CI. +- **Official .NET web export is the finish line.** The full status, dependency chain, + exit criteria and staged migration plan now live in + **[WEB_EXPORT_ROADMAP.md](./WEB_EXPORT_ROADMAP.md)** — that's the document to read and + keep updated. Short version: LibGodot Core + [#110863](https://github.com/godotengine/godot/pull/110863) **merged and shipped in + 4.6**, with [#121502](https://github.com/godotengine/godot/pull/121502) (LibGodot on + web) and [#118976](https://github.com/godotengine/godot/pull/118976) (.NET on top) + still open at milestone `4.x`. Direction is good; no date. +- **Single-maintainer fork risk — lower than first assumed.** The editor comes from one + community fork (`ComplexRobot/godot-dotnet-web-export`), but it tracked upstream + 4.7.1 (released 2026-07-14) with a matching build on **2026-07-16** and also ships + 4.8 dev snapshots — actively maintained, not stale. Mitigations in place: + `GODOT_FORK_REPO`/tag are **config** (one-line repoint at a mirror), and the binary + is **checksum-pinned**. Still outstanding: **mirror the exact editor zip you rely on** + to storage you control, so a deleted upstream release can't break CI — tracked as + [Phase 0](./WEB_EXPORT_ROADMAP.md#phase-0--reduce-hack-surface-actionable-now-no-upstream-dependency). - **Runtime feature gaps can bite silently.** The patched runtime has **no GDExtension**, **forced invariant globalization**, and **missing crypto BCL APIs**. Code using culture-specific formatting/parsing or `System.Security.Cryptography` will work on desktop and **fail only on web**. The smoke test catches a dead boot, - not subtle feature divergence — if you add such code, test it on web explicitly. + not subtle feature divergence — if you add such code, test it on web explicitly via + `/preview`. A static guard to catch this at PR time is + [Phase 0](./WEB_EXPORT_ROADMAP.md#phase-0--reduce-hack-surface-actionable-now-no-upstream-dependency) + work and **not yet implemented**. - **Payload & caching.** The build is ~96 MB (54 MB `index.wasm` + 42 MB `index.pck`). Vercel already serves it **Brotli-compressed**, and assets use `must-revalidate` (ETag → 304), so there's no staleness. The remaining win — @@ -225,7 +276,9 @@ something to monitor, not set-and-forget. action from the base ref instead of PR head. - **Windows runner minutes.** The export runs on `windows-latest` (billed at a premium) on every merge to `main` and every `/preview`. Editor download is cached - by fork tag. If minutes get tight, gate the production export to release tags only. + by **fork repo + tag** (so repointing at a mirror correctly misses the cache rather + than reusing the old binary). If minutes get tight, gate the production export to + release tags only. Phase 3 of the roadmap removes the Windows runner entirely. - **Vercel token hygiene.** CI uses a scoped `VERCEL_TOKEN`. Rotate it periodically; if it expires, deploys fail at the deploy step (export still succeeds) — re-run `gh secret set VERCEL_TOKEN`. diff --git a/docs/WEB_EXPORT_ROADMAP.md b/docs/WEB_EXPORT_ROADMAP.md new file mode 100644 index 0000000..ef66bc2 --- /dev/null +++ b/docs/WEB_EXPORT_ROADMAP.md @@ -0,0 +1,203 @@ +# Web Export: Road to Stable + +**Purpose.** [`WEB_EXPORT.md`](./WEB_EXPORT.md) documents how the experimental C#→WASM +export works *today*. This document is the plan for getting it onto **officially +supported** foundations — what we're waiting for, how we'll know it's ready, what we +migrate in what order, and what we do in the meantime. + +**Last reviewed:** 2026-07-25 · **Next review due:** 2026-10-25 (quarterly — see +[Monitoring](#monitoring)) + +--- + +## 1. Where things actually stand + +Official Godot **still cannot** export C#/.NET projects to the web. The hosting hack +remains necessary. But the upstream approach changed materially in the last year, and +the change is favourable. + +### The root problem (unchanged) + +.NET's WASM build expects to **be the main module** and does not support dynamic +linking, so Godot cannot load it as a library. Everything below is a strategy for +getting around that single fact. + +### Upstream state as of 2026-07-25 + +| Item | State | Note | +|---|---|---| +| [#70796](https://github.com/godotengine/godot/issues/70796) — tracking issue | **Open** since Jan 2023 | No milestone. The canonical "is it done yet" link. | +| [#106125](https://github.com/godotengine/godot/pull/106125) — `[.NET] Add web export support` (raulsntos) | **Draft** | The original prototype, and the ancestor of the fork we use. Stalled on .NET 10 compatibility. Effectively superseded. | +| [#110863](https://github.com/godotengine/godot/pull/110863) — `LibGodot: Core` | ✅ **Merged 2025-10-08 — shipped in Godot 4.6** | Builds the engine as a library. The foundation of the new approach. | +| [#118976](https://github.com/godotengine/godot/pull/118976) — `[.NET] web export using static LibGodot` | **Draft**, milestone `4.x` | The .NET-specific consumer of LibGodot. This is the one that ends the hack. | +| [#121502](https://github.com/godotengine/godot/pull/121502) — `Extend LibGodot Core to the web platform` | **Open** (2026-07-17), milestone `4.x` | Brings LibGodot to web. **Core-level, not C#-specific** — its description does not mention .NET. | + +### What changed, and why it matters + +The strategy pivoted from *"patch the .NET runtime into the engine's WASM"* (#106125 — +fragile, stalled) to *"make Godot a library so .NET **can** be the main module"* +(LibGodot). That reframing dissolves the root problem instead of working around it, +and **its first half is already in a shipped release (4.6)**. + +Read the dependency chain as: + +``` +LibGodot: Core (#110863) ──✅ merged, in 4.6 + └─> LibGodot on web (#121502) ──open, milestone 4.x + └─> .NET web export on static LibGodot (#118976) ──draft, milestone 4.x + └─> official C# web export ──not scheduled +``` + +**Honest read on timing:** two open, unscheduled PRs still sit between us and official +support, and milestone `4.x` means "no committed release". Plan for **4.9 at the +earliest**, and do not plan work around it landing. The direction is now good; the +date is not knowable. + +--- + +## 2. Definition of done + +We declare the web export **stable** and retire the hack when *all* of these hold: + +1. A **stable** (not dev/beta/RC) official Godot release can export a C#/.NET project + to web using **official export templates** — no patched editor. +2. The official path runs on **Linux CI**, letting us drop the `windows-latest` runner. +3. Our project exports and **boots** on it — verified by the existing + [`smoke.mjs`](../.github/smoke/smoke.mjs) test, unchanged. +4. The runtime limitations we currently accept are either resolved or still acceptable: + invariant globalization, no GDExtension, missing crypto BCL APIs. +5. `Godot.NET.Sdk` and the export templates come from the same official release, so + the version-drift guard collapses to a single upstream version. + +Criteria 1–3 are the hard gates. 4–5 are cleanup that follows. + +--- + +## 3. Migration plan + +Staged so each phase is independently valuable and independently revertible. Phases 0 +and 1 need no upstream progress; 2 onward are trigger-driven. + +### Phase 0 — Reduce hack surface *(actionable now, no upstream dependency)* + +Make the current setup cheap to maintain and cheap to leave. + +- [x] **Single source of truth for toolchain versions** — [`.github/web-toolchain.env`](../.github/web-toolchain.env). + Previously the tag/template were duplicated across the composite action's + defaults, the `workflow_dispatch` defaults and inline fallbacks in the + `production` job, so `production` and `/preview` could silently build different + toolchains. +- [x] **Patch-level drift guard.** The guard compared only `major.minor`, so a + 4.7.0-vs-4.7.1 mismatch passed. It now normalises all three spellings + (`4.7.1` / `4.7.1.stable.mono` / `4.7.1-stable`) and requires exact agreement, + and fails *before* the 165 MB editor download if the target tag has no pinned + checksum. +- [ ] **Mirror the editor binary.** The single highest-value remaining item. Copy the + exact editor zip we depend on to storage we control (a release asset on this + repo, or S3), then point `GODOT_FORK_REPO` at it. Today a deleted upstream + release breaks CI outright. The checksum pin means a mirror is trivially + verifiable — it must hash to the value already in + [`editor-checksums.txt`](../.github/editor-checksums.txt). +- [ ] **Guard the feature gaps in code, not prose.** Invariant globalization and the + missing crypto APIs fail *only on web* — desktop builds and the test suite stay + green. Add an analyzer/test that rejects culture-sensitive formatting and + `System.Security.Cryptography` usage in `scripts/`, so the failure surfaces at + PR time rather than on a deploy. + +### Phase 1 — Track upstream releases promptly *(routine)* + +Stay on the current *stable* Godot line. The fork has proven fast (§4), so this is +low-cost, and staying current means the eventual official switch is a small diff +rather than a multi-version jump. + +- Follow **stable** fork releases only. **Skip `dev`/`beta`/`rc` builds** — there's no + upside for a hosted demo. +- Procedure: [`WEB_EXPORT.md` → "Updating the pinned editor"](./WEB_EXPORT.md#updating-the-pinned-editor). + +### Phase 2 — Evaluate on the first official preview + +**Trigger:** #118976 (or a successor) merges, *or* an official dev/beta snapshot ships +C# web export templates. + +- Branch, point `GODOT_FORK_REPO`/`GODOT_FORK_TAG` at the official build, `/preview`, + and let the smoke test judge it. **Do not touch production.** +- Record what breaks. Expect friction around `OutputType=Exe`, `Program.cs`, + `InvariantGlobalization` and the `TrimmerRootAssembly` roots — the official path may + need none of them, some of them, or different ones. +- Outcome: a written go/no-go against §2, not a deploy. + +### Phase 3 — Switch production + +**Trigger:** §2 criteria 1–3 all satisfied on a stable release. + +1. Bump `Godot.NET.Sdk`, the .NET SDK in `global.json` if required, and the toolchain + file to the official version. +2. Move the export job from `windows-latest` to `ubuntu-latest`. +3. Delete the fork-specific machinery from the composite action: editor download, + checksum verification, self-contained `._sc_` template install, bundled NuGet + source registration. Replace with the standard `godot --headless --export-release` + against official templates. +4. **Keep unchanged:** the Vercel project, `vercel.json` COOP/COEP headers, and + `smoke.mjs`. Cross-origin isolation is a *hosting* requirement of threaded WASM, + not an artefact of the fork — it will still be needed. +5. Retire `.github/editor-checksums.txt` and the fork rows of the version matrix. + +### Phase 4 — Clean up project-side workarounds + +**Trigger:** production green on the official path for two weeks. + +- Re-test whether `Program.cs`, `OutputType=Exe` and `InvariantGlobalization=true` are + still required; drop each that isn't, and with it the conditional-compilation + complexity in the `.csproj`. +- Revisit `immutable` long-cache headers if official export produces content-hashed + filenames (see [`WEB_EXPORT.md`](./WEB_EXPORT.md) → payload & caching). +- Fold this document's remaining content back into `WEB_EXPORT.md` and delete it. + +--- + +## 4. Risk register + +| Risk | Current assessment | Mitigation | +|---|---|---| +| **Fork goes stale** | **Lower than previously assumed.** The fork tracked upstream 4.7.1 (released 2026-07-14) with a matching build on **2026-07-16** — two days — and also ships 4.8 dev snapshots. It is actively maintained, not abandoned. | `GODOT_FORK_REPO` is an input, so repointing is a one-line change. **Still do the mirror** (Phase 0) — maintained today ≠ available forever. | +| **Upstream release deleted/republished** | Breaks CI immediately; checksum mismatch would also fail the build. | Checksum pin catches substitution. Mirror (Phase 0) removes the availability risk. | +| **Silent runtime feature divergence** | Real and unaddressed. Culture-sensitive formatting or crypto passes desktop tests and fails only in the browser; the smoke test catches a dead boot, not subtle breakage. | Phase 0 static guard. Until then, test on web explicitly via `/preview` when touching formatting or crypto. | +| **Both upstream PRs stall** | Plausible — #106125 already did. | Phase 0/1 keep the hack maintainable indefinitely. Nothing here has a deadline. | +| **Windows runner cost** | Windows minutes bill at a premium; runs on every merge to `main` and every `/preview`. | Editor download is cached by repo+tag. If minutes get tight, gate production export to release tags. Phase 3 removes this entirely. | +| **`/preview` trust boundary** | Owner-only, but checks out PR head with secrets in scope. | Keep it `OWNER`-only. If collaborators are ever added, run the action from the base ref instead. | + +--- + +## 5. Monitoring + +Quarterly is the right cadence — this moves on a scale of engine releases, not weeks. +Checking more often is wasted effort; not checking means missing the switch. + +**Each review (next due 2026-10-25):** + +1. Check the four upstream links in §1 for state changes. #121502 → #118976 is the + chain that matters; #70796 closing is the unambiguous signal. +2. Check the [fork's releases](https://github.com/ComplexRobot/godot-dotnet-web-export/releases) + for a newer **stable** tag; if there is one, do a Phase 1 bump. +3. Confirm the live demo still boots (the smoke test covers this on every deploy, but + confirm a deploy has actually run since the last review). +4. Update §1's table, the "Last reviewed" date, and this file's next-review date. + +Also worth a look at each **new Godot minor release's** release notes — official C# +web support would be headline news there, and 4.6 shipping LibGodot Core is exactly +the kind of change that showed up that way. + +--- + +## 6. Rollback + +If a toolchain bump breaks the deploy, revert in this order — each step is independent: + +1. **Revert the pin.** Set `GODOT_FORK_TAG` / `GODOT_TEMPLATE_VERSION` in + [`web-toolchain.env`](../.github/web-toolchain.env) back to the previous values and + `Godot.NET.Sdk` in the `.csproj` to match. Superseded checksums are kept in + `editor-checksums.txt` deliberately, so a rollback needs no checksum work. +2. **Re-deploy.** Push to `main`, or promote the last-known-good deployment in the + Vercel dashboard for an immediate fix without waiting on a Windows export. +3. The drift guard will reject a partial revert — if it fires, one of the three + version rows was missed. From 0b3a5c9bee6734b26b9444513a8d44e901103ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 21:45:46 +0000 Subject: [PATCH 2/4] fix(test): run one fixture instance per test case, fixing a parallelism data race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[assembly: Parallelizable(ParallelScope.All)]` was combined with NUnit's default SingleInstance lifecycle, so test cases in a fixture ran concurrently against one shared instance. Every field assigned in `[SetUp]` was therefore a data race. It surfaced as two NullReferenceExceptions in RandomValueTests on CI, on a commit whose other runs of the same job were green — re-running the identical commit passed, confirming a race rather than an environment difference. The mechanism: _mazePointFactory = new Mock(); // (a) bare mock _mazePointFactory.Setup(x => x.MakePoint(...)).Returns(...); // (b) configures it _randomPoint = new RandomPointGenerator(_random, _mazePointFactory.Object); // (c) re-reads the field Another test's (a) landing between this test's (b) and (c) makes (c) capture an unconfigured mock. Moq then returns default(MazePoint) — null — and the test dies dereferencing `point.X`, nowhere near the actual cause. Measured with a trace harness (NUnit's per-test console capture reorders output and hides this): with SingleInstance, four concurrent `[SetUp]` bodies share one fixture instance; with InstancePerTestCase, four concurrent tests get four distinct instances. Parallelism is unchanged, the unsafe sharing is gone. Also drops MovementHelperTests' `[NonParallelizable]`, which was a local workaround for this same root cause, and adds a guard test so removing the attribute fails immediately by name instead of resurfacing as an occasional unexplained flake. --- tests/MovementHelperTests.cs | 4 ++- tests/TestSetup.cs | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/MovementHelperTests.cs b/tests/MovementHelperTests.cs index c2d0f36..2e5134f 100644 --- a/tests/MovementHelperTests.cs +++ b/tests/MovementHelperTests.cs @@ -11,7 +11,9 @@ namespace ProceduralMaze.Tests; /// Migrated from the original ProcGenMaze.Test project. /// [TestFixture] -[NonParallelizable] // Tests in this fixture share mock state and cannot run in parallel +// Was [NonParallelizable] because the tests shared per-fixture mock state. That is no longer +// true: the assembly runs InstancePerTestCase (see TestSetup.cs), so each test gets its own +// fields and can run in parallel safely. public class MovementHelperTests { private IMovementHelper _movementHelper = null!; diff --git a/tests/TestSetup.cs b/tests/TestSetup.cs index 2178f4b..ab0f3a0 100644 --- a/tests/TestSetup.cs +++ b/tests/TestSetup.cs @@ -3,3 +3,64 @@ // Enable parallel test execution at the assembly level // ParallelScope.All runs test fixtures and their children in parallel [assembly: Parallelizable(ParallelScope.All)] + +// One fixture instance per test case. Not a performance choice — it is what makes +// ParallelScope.All above *correct*. +// +// Measured under this project's NUnit (3.14) with ParallelScope.All: NUnit creates ONE fixture +// instance and runs its test cases on several worker threads, so multiple `[SetUp]` bodies are +// in flight against the same instance simultaneously. Every field a `[SetUp]` assigns is +// therefore a data race between tests of the same fixture. +// +// The resulting failure points nowhere near the cause. `RandomValueTests` does: +// +// _mazePointFactory = new Mock(); // (a) publishes a BARE mock +// _mazePointFactory.Setup(x => x.MakePoint(...)).Returns(...); // (b) configures the field's mock +// _randomPoint = new RandomPointGenerator(_random, _mazePointFactory.Object); // (c) RE-READS the field +// +// If another test's (a) lands between this test's (b) and (c), then (c) captures that other +// thread's unconfigured mock. A loose Moq mock returns default(MazePoint) — null, because +// MazePoint is a class — so `RandomPoint` hands back null and the test dies dereferencing +// `point.X` inside an assertion loop. That is exactly how it presented: two NullReferenceExceptions +// in RandomValueTests on CI, on a commit whose other CI runs were green and which passed every +// local run. `MovementHelperTests` was marked `[NonParallelizable]` for the same root cause. +// +// InstancePerTestCase gives each test its own fixture instance (verified: four concurrent tests, +// four distinct instances), so no `[SetUp]` can observe another's half-built state. Parallelism is +// kept — the unsafe sharing is not. +// +// Constraint this imposes: `[OneTimeSetUp]`/`[OneTimeTearDown]` must be static under this +// lifecycle. There are none in this assembly, and any added later must be static. +[assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)] + +namespace ProceduralMaze.Tests; + +/// +/// Guards the assembly-level test lifecycle above. +/// +/// +/// Without this, deleting [assembly: FixtureLifeCycle(...)] reintroduces a race whose only +/// symptom is an occasional NullReferenceException in an unrelated-looking test — the kind of +/// regression that gets re-diagnosed from scratch months later, or dismissed as "CI being flaky". +/// This turns that into an immediate, named failure. +/// +[TestFixture] +public class TestLifecycleGuardTests +{ + [Test] + public void Assembly_RunsOneFixtureInstancePerTestCase() + { + var attribute = typeof(TestLifecycleGuardTests).Assembly + .GetCustomAttributes(typeof(FixtureLifeCycleAttribute), false) + .Cast() + .SingleOrDefault(); + + Assert.That(attribute, Is.Not.Null, + "The test assembly must declare [assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)]. " + + "Without it, ParallelScope.All runs test cases concurrently against a single shared " + + "fixture instance, making every field assigned in [SetUp] a data race. See TestSetup.cs."); + + Assert.That(attribute!.LifeCycle, Is.EqualTo(LifeCycle.InstancePerTestCase), + "SingleInstance is unsafe in combination with ParallelScope.All. See TestSetup.cs."); + } +} From 13e49ae23e7e32fb8ba372770847647dc42a62bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 08:20:42 +0000 Subject: [PATCH 3/4] chore: gitignore node_modules and Playwright run output This branch and `main` had no Node or Playwright ignore rules, so nothing stopped an `npm ci` in tests/visual/ from being committed. That is exactly how 170 files / 17.7 MiB of node_modules got into the GitHub-Actions branch (#7), which is also based on `main`. Later branches in this stack do carry `tests/visual/`-anchored rules, added with the visual-regression harness. These are deliberately repo-wide rather than anchored: the trap is a node_modules appearing somewhere the anchored pattern doesn't cover. Baseline screenshots under *-snapshots/ stay tracked; only regenerated run output is ignored. --- .gitignore | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 2e1a838..b77522c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,13 @@ Thumbs.db # Godot UID files (regenerated by Godot) *.uid + +# Node.js dependencies (the Playwright suite under tests/visual/) +node_modules/ + +# Playwright run output — regenerated on every run. +# Baseline screenshots live in *-snapshots/ and ARE tracked; do not add them here. +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ From 865c1a947fc20f2fde176292348e6c6a3178cc26 Mon Sep 17 00:00:00 2001 From: Ryan Kelly <94ryan.kelly@gmail.com> Date: Mon, 27 Jul 2026 21:55:11 +0100 Subject: [PATCH 4/4] refactor(ci): extract export-web inline PowerShell into scripts/*.ps1 Each composite-action step now invokes a dedicated script under .github/actions/export-web/scripts/, keeping action.yml to wiring (env, ids, outputs). The drift-guard error message becomes a plain here-string now that it lives outside a YAML block scalar. All scripts parse clean; resolve-toolchain and check-version-drift verified locally including the drift-failure path. Co-Authored-By: Claude Fable 5 --- .github/actions/export-web/action.yml | 153 ++---------------- .../scripts/check-version-drift.ps1 | 43 +++++ .../export-web/scripts/download-editor.ps1 | 9 ++ .../actions/export-web/scripts/export-web.ps1 | 12 ++ .../export-web/scripts/install-templates.ps1 | 18 +++ .../export-web/scripts/locate-editor.ps1 | 17 ++ .../scripts/register-nuget-source.ps1 | 11 ++ .../export-web/scripts/resolve-toolchain.ps1 | 37 +++++ .../scripts/verify-editor-checksum.ps1 | 26 +++ 9 files changed, 185 insertions(+), 141 deletions(-) create mode 100644 .github/actions/export-web/scripts/check-version-drift.ps1 create mode 100644 .github/actions/export-web/scripts/download-editor.ps1 create mode 100644 .github/actions/export-web/scripts/export-web.ps1 create mode 100644 .github/actions/export-web/scripts/install-templates.ps1 create mode 100644 .github/actions/export-web/scripts/locate-editor.ps1 create mode 100644 .github/actions/export-web/scripts/register-nuget-source.ps1 create mode 100644 .github/actions/export-web/scripts/resolve-toolchain.ps1 create mode 100644 .github/actions/export-web/scripts/verify-editor-checksum.ps1 diff --git a/.github/actions/export-web/action.yml b/.github/actions/export-web/action.yml index 680f74a..afbb0bc 100644 --- a/.github/actions/export-web/action.yml +++ b/.github/actions/export-web/action.yml @@ -2,6 +2,7 @@ name: "Export Godot C# project to Web (WASM)" description: > Builds an experimental C#/.NET WebAssembly export using ComplexRobot's patched Windows editor build. Must run on a windows-latest runner. Produces build/web. + Step logic lives in ./scripts/*.ps1. inputs: # These three intentionally default to EMPTY. When empty they are resolved from @@ -46,37 +47,7 @@ runs: IN_FORK_REPO: ${{ inputs.fork_repo }} IN_FORK_TAG: ${{ inputs.godot_fork_tag }} IN_TEMPLATE_VERSION: ${{ inputs.template_version }} - run: | - # Read the shared toolchain file, then let non-empty inputs override it. - $path = ".github/web-toolchain.env" - if (-not (Test-Path $path)) { throw "Missing $path — the web export toolchain versions live there. See docs/WEB_EXPORT.md." } - $cfg = @{} - foreach ($line in Get-Content $path) { - if ($line -match '^\s*#' -or $line -notmatch '=') { continue } - $k, $v = $line -split '=', 2 - $cfg[$k.Trim()] = $v.Trim() - } - - function Resolve-Value($inputValue, $key) { - if (-not [string]::IsNullOrWhiteSpace($inputValue)) { - Write-Host "$key = $inputValue (overridden by workflow input)" - return $inputValue - } - if (-not $cfg.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($cfg[$key])) { - throw "$key is not set in .github/web-toolchain.env and no override was supplied." - } - Write-Host "$key = $($cfg[$key]) (from .github/web-toolchain.env)" - return $cfg[$key] - } - - $forkRepo = Resolve-Value $env:IN_FORK_REPO 'GODOT_FORK_REPO' - $forkTag = Resolve-Value $env:IN_FORK_TAG 'GODOT_FORK_TAG' - $template = Resolve-Value $env:IN_TEMPLATE_VERSION 'GODOT_TEMPLATE_VERSION' - - "fork_repo=$forkRepo" >> $env:GITHUB_OUTPUT - "fork_tag=$forkTag" >> $env:GITHUB_OUTPUT - "template_version=$template" >> $env:GITHUB_OUTPUT - "asset=Godot_v${forkTag}_mono_web_export_win64.zip" >> $env:GITHUB_OUTPUT + run: '& "${{ github.action_path }}/scripts/resolve-toolchain.ps1"' - name: Guard against version drift shell: pwsh @@ -84,52 +55,7 @@ runs: TEMPLATE_VERSION: ${{ steps.toolchain.outputs.template_version }} FORK_TAG: ${{ steps.toolchain.outputs.fork_tag }} ASSET: ${{ steps.toolchain.outputs.asset }} - run: | - # The Godot.NET.Sdk version (csproj), the patched-editor tag, and the - # export-template folder must agree at PATCH level — comparing only - # major.minor lets a 4.7.0-vs-4.7.1 mismatch through, which is exactly the - # class of bump that happens most often. Catch it in seconds, before we - # download a 165 MB editor. - # - # Normalise the three spellings of the same version to a bare x.y[.z]: - # csproj "4.7.1" -> 4.7.1 ("4.7.0" -> 4.7, see below) - # template "4.7.1.stable.mono" -> 4.7.1 - # fork tag "4.7.1-stable" -> 4.7.1 - # Godot names x.y releases without a patch component, so an SDK version of - # "4.7.0" corresponds to template "4.7.stable.mono": strip a trailing ".0". - function Get-BareVersion([string]$s) { - return ($s -replace '[-.](stable|beta|rc|dev)[-.0-9]*.*$', '') - } - - $csproj = Get-Content ProceduralGeneration3DMazes.csproj -Raw - if ($csproj -notmatch 'Godot\.NET\.Sdk/(\d+\.\d+\.\d+)') { throw "Could not parse Godot.NET.Sdk version from csproj." } - $sdkVersion = $Matches[1] -replace '\.0$', '' - $tplVersion = Get-BareVersion $env:TEMPLATE_VERSION - $tagVersion = Get-BareVersion $env:FORK_TAG - - $mismatch = ($sdkVersion -ne $tplVersion) -or ($sdkVersion -ne $tagVersion) - if ($mismatch) { - # Built as an array rather than a here-string: a PowerShell here-string needs - # its closing "@ at column 0, which would terminate this YAML block scalar. - throw (@( - "Version drift - these must all describe the same Godot version:", - " Godot.NET.Sdk (csproj) -> $sdkVersion", - " template_version -> $tplVersion (raw: $env:TEMPLATE_VERSION)", - " godot_fork_tag -> $tagVersion (raw: $env:FORK_TAG)", - "Align them in ProceduralGeneration3DMazes.csproj and .github/web-toolchain.env.", - "See docs/WEB_EXPORT.md -> 'Updating the pinned editor'." - ) -join "`n") - } - - # Fail fast if the editor we're about to fetch has no pinned checksum, rather - # than after spending a runner-minute downloading it. - $pinned = Get-Content .github/editor-checksums.txt | - Where-Object { $_ -notmatch '^\s*#' -and $_ -match [regex]::Escape($env:ASSET) } - if (-not $pinned) { - throw "No pinned SHA-256 for '$env:ASSET' in .github/editor-checksums.txt — refusing to run an unverified binary. See docs/WEB_EXPORT.md -> 'Updating the pinned editor'." - } - - Write-Host "Version check OK: Godot.NET.Sdk, templates and editor tag all agree on $sdkVersion (checksum pinned for $env:ASSET)." + run: '& "${{ github.action_path }}/scripts/check-version-drift.ps1"' - name: Set up .NET 9 SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -155,35 +81,13 @@ runs: FORK_REPO: ${{ steps.toolchain.outputs.fork_repo }} FORK_TAG: ${{ steps.toolchain.outputs.fork_tag }} ASSET: ${{ steps.toolchain.outputs.asset }} - run: | - New-Item -ItemType Directory -Force -Path "${env:RUNNER_TEMP}/godot-zip" | Out-Null - Write-Host "Downloading $env:ASSET from $env:FORK_REPO@$env:FORK_TAG" - gh release download "$env:FORK_TAG" --repo "$env:FORK_REPO" --pattern "$env:ASSET" --dir "${env:RUNNER_TEMP}/godot-zip" + run: '& "${{ github.action_path }}/scripts/download-editor.ps1"' - name: Verify editor checksum (supply-chain guard) shell: pwsh env: ASSET: ${{ steps.toolchain.outputs.asset }} - run: | - # Runs on both fresh downloads and cache hits — verifies the exact bytes we execute. - # Match the expected filename explicitly: a restored cache must not be able to - # substitute a differently-named editor zip for the one we pinned. - $zip = Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -Filter *.zip | - Where-Object { $_.Name -eq $env:ASSET } | Select-Object -First 1 - if (-not $zip) { - Write-Host "Contents of ${env:RUNNER_TEMP}/godot-zip:" - Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $($_.Name)" } - throw "Expected editor zip '$env:ASSET' not found (cache or download failed)." - } - $line = Get-Content .github/editor-checksums.txt | - Where-Object { $_ -notmatch '^\s*#' -and $_ -match [regex]::Escape($zip.Name) } | Select-Object -First 1 - if (-not $line) { throw "No pinned checksum for '$($zip.Name)' in .github/editor-checksums.txt — refusing to run an unverified binary. See docs/WEB_EXPORT.md." } - $expected = (($line -split '\s+') | Where-Object { $_ })[0].ToLower() - $actual = (Get-FileHash $zip.FullName -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected) { - throw "Checksum MISMATCH for $($zip.Name).`n expected: $expected`n actual: $actual`nThe fork release may have been re-published — verify and update .github/editor-checksums.txt." - } - Write-Host "Checksum OK for $($zip.Name): $actual" + run: '& "${{ github.action_path }}/scripts/verify-editor-checksum.ps1"' - name: Extract editor shell: pwsh @@ -194,46 +98,18 @@ runs: - name: Locate editor executable and bundle contents id: locate shell: pwsh - run: | - $root = "$env:RUNNER_TEMP/godot" - $exe = Get-ChildItem -Path $root -Recurse -Filter "*.exe" | - Where-Object { $_.Name -match "console" } | Select-Object -First 1 - if (-not $exe) { - $exe = Get-ChildItem -Path $root -Recurse -Filter "*.exe" | - Where-Object { $_.Name -notmatch "crash|handler" } | Select-Object -First 1 - } - if (-not $exe) { throw "Could not find a Godot editor executable in the fork zip." } - Write-Host "Editor exe: $($exe.FullName)" - "godot_exe=$($exe.FullName)" >> $env:GITHUB_OUTPUT - "bundle_dir=$($exe.Directory.FullName)" >> $env:GITHUB_OUTPUT + run: '& "${{ github.action_path }}/scripts/locate-editor.ps1"' - name: Install web export templates (self-contained mode) shell: pwsh env: + BUNDLE_DIR: ${{ steps.locate.outputs.bundle_dir }} TEMPLATE_VERSION: ${{ steps.toolchain.outputs.template_version }} - run: | - $bundle = "${{ steps.locate.outputs.bundle_dir }}" - New-Item -ItemType File -Force -Path (Join-Path $bundle "._sc_") | Out-Null - $tplDir = Join-Path $bundle "editor_data/export_templates/${env:TEMPLATE_VERSION}" - New-Item -ItemType Directory -Force -Path $tplDir | Out-Null - $templates = Get-ChildItem -Path "$env:RUNNER_TEMP/godot" -Recurse -Include "web_release.zip","web_debug.zip" - if (-not $templates) { - Get-ChildItem -Path "$env:RUNNER_TEMP/godot" -Recurse -File | Select-Object -ExpandProperty FullName - throw "No web_release.zip / web_debug.zip found in the fork bundle." - } - $templates | ForEach-Object { Copy-Item $_.FullName -Destination $tplDir -Force; Write-Host "Installed template: $($_.Name)" } - Write-Host "Templates in ${tplDir}:"; Get-ChildItem $tplDir + run: '& "${{ github.action_path }}/scripts/install-templates.ps1"' - name: Register bundled NuGet source (if present) shell: pwsh - run: | - $nuget = Get-ChildItem -Path "$env:RUNNER_TEMP/godot" -Recurse -Directory -Filter "nuget" | Select-Object -First 1 - if ($nuget) { - Write-Host "Adding local NuGet source: $($nuget.FullName)" - dotnet nuget add source "$($nuget.FullName)" --name godot-web-fork - } else { - Write-Host "No bundled nuget folder found; relying on nuget.org." - } + run: '& "${{ github.action_path }}/scripts/register-nuget-source.ps1"' - name: Restore / build (net9.0) shell: pwsh @@ -247,14 +123,9 @@ runs: - name: Export Web (headless CLI) shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path "build/web" | Out-Null - & "${{ steps.locate.outputs.godot_exe }}" --headless --path . --export-release "Web" "build/web/index.html" 2>&1 | Tee-Object -FilePath export.log - if (-not (Test-Path "build/web/index.html")) { - Write-Host "::error::Export did not produce build/web/index.html — see export.log" - exit 1 - } - Write-Host "Export output:"; Get-ChildItem build/web + env: + GODOT_EXE: ${{ steps.locate.outputs.godot_exe }} + run: '& "${{ github.action_path }}/scripts/export-web.ps1"' - name: Upload web export artifact if: always() diff --git a/.github/actions/export-web/scripts/check-version-drift.ps1 b/.github/actions/export-web/scripts/check-version-drift.ps1 new file mode 100644 index 0000000..08b35c5 --- /dev/null +++ b/.github/actions/export-web/scripts/check-version-drift.ps1 @@ -0,0 +1,43 @@ +# Fail fast (before the 165 MB editor download) if the Godot.NET.Sdk version (csproj), +# the patched-editor tag, and the export-template folder disagree at PATCH level, or if +# the target editor asset has no pinned checksum. +# +# The three values spell the same version differently; normalise to bare x.y[.z]: +# csproj "4.7.1" -> 4.7.1 +# template "4.7.1.stable.mono" -> 4.7.1 +# fork tag "4.7.1-stable" -> 4.7.1 +# Godot names x.y releases without a patch component (template "4.7.stable.mono"), +# so a csproj SDK of "4.7.0" normalises to "4.7". +# +# Env in: TEMPLATE_VERSION, FORK_TAG, ASSET + +$ErrorActionPreference = 'Stop' + +function Get-BareVersion([string]$s) { + return ($s -replace '[-.](stable|beta|rc|dev)[-.0-9]*.*$', '') +} + +$csproj = Get-Content ProceduralGeneration3DMazes.csproj -Raw +if ($csproj -notmatch 'Godot\.NET\.Sdk/(\d+\.\d+\.\d+)') { throw "Could not parse Godot.NET.Sdk version from csproj." } +$sdkVersion = $Matches[1] -replace '\.0$', '' +$tplVersion = Get-BareVersion $env:TEMPLATE_VERSION +$tagVersion = Get-BareVersion $env:FORK_TAG + +if (($sdkVersion -ne $tplVersion) -or ($sdkVersion -ne $tagVersion)) { + throw @" +Version drift - these must all describe the same Godot version: + Godot.NET.Sdk (csproj) -> $sdkVersion + template_version -> $tplVersion (raw: $env:TEMPLATE_VERSION) + godot_fork_tag -> $tagVersion (raw: $env:FORK_TAG) +Align them in ProceduralGeneration3DMazes.csproj and .github/web-toolchain.env. +See docs/WEB_EXPORT.md -> 'Updating the pinned editor'. +"@ +} + +$pinned = Get-Content .github/editor-checksums.txt | + Where-Object { $_ -notmatch '^\s*#' -and $_ -match [regex]::Escape($env:ASSET) } +if (-not $pinned) { + throw "No pinned SHA-256 for '$env:ASSET' in .github/editor-checksums.txt — refusing to run an unverified binary. See docs/WEB_EXPORT.md -> 'Updating the pinned editor'." +} + +Write-Host "Version check OK: Godot.NET.Sdk, templates and editor tag all agree on $sdkVersion (checksum pinned for $env:ASSET)." diff --git a/.github/actions/export-web/scripts/download-editor.ps1 b/.github/actions/export-web/scripts/download-editor.ps1 new file mode 100644 index 0000000..009d92c --- /dev/null +++ b/.github/actions/export-web/scripts/download-editor.ps1 @@ -0,0 +1,9 @@ +# Download the patched Godot editor zip from the fork release. +# Env in: GH_TOKEN, FORK_REPO, FORK_TAG, ASSET + +$ErrorActionPreference = 'Stop' + +New-Item -ItemType Directory -Force -Path "${env:RUNNER_TEMP}/godot-zip" | Out-Null +Write-Host "Downloading $env:ASSET from $env:FORK_REPO@$env:FORK_TAG" +gh release download "$env:FORK_TAG" --repo "$env:FORK_REPO" --pattern "$env:ASSET" --dir "${env:RUNNER_TEMP}/godot-zip" +if ($LASTEXITCODE -ne 0) { throw "gh release download failed (exit $LASTEXITCODE)." } diff --git a/.github/actions/export-web/scripts/export-web.ps1 b/.github/actions/export-web/scripts/export-web.ps1 new file mode 100644 index 0000000..8111a5f --- /dev/null +++ b/.github/actions/export-web/scripts/export-web.ps1 @@ -0,0 +1,12 @@ +# Run the headless web export and fail loudly if no output was produced. +# Env in: GODOT_EXE + +$ErrorActionPreference = 'Stop' + +New-Item -ItemType Directory -Force -Path "build/web" | Out-Null +& "$env:GODOT_EXE" --headless --path . --export-release "Web" "build/web/index.html" 2>&1 | Tee-Object -FilePath export.log +if (-not (Test-Path "build/web/index.html")) { + Write-Host "::error::Export did not produce build/web/index.html — see export.log" + exit 1 +} +Write-Host "Export output:"; Get-ChildItem build/web diff --git a/.github/actions/export-web/scripts/install-templates.ps1 b/.github/actions/export-web/scripts/install-templates.ps1 new file mode 100644 index 0000000..6923193 --- /dev/null +++ b/.github/actions/export-web/scripts/install-templates.ps1 @@ -0,0 +1,18 @@ +# Install the bundled web export templates in self-contained mode (next to the +# editor, via a "._sc_" marker — no AppData). +# Env in: BUNDLE_DIR, TEMPLATE_VERSION + +$ErrorActionPreference = 'Stop' + +$bundle = $env:BUNDLE_DIR +New-Item -ItemType File -Force -Path (Join-Path $bundle "._sc_") | Out-Null +$tplDir = Join-Path $bundle "editor_data/export_templates/${env:TEMPLATE_VERSION}" +New-Item -ItemType Directory -Force -Path $tplDir | Out-Null + +$templates = Get-ChildItem -Path "$env:RUNNER_TEMP/godot" -Recurse -Include "web_release.zip","web_debug.zip" +if (-not $templates) { + Get-ChildItem -Path "$env:RUNNER_TEMP/godot" -Recurse -File | Select-Object -ExpandProperty FullName + throw "No web_release.zip / web_debug.zip found in the fork bundle." +} +$templates | ForEach-Object { Copy-Item $_.FullName -Destination $tplDir -Force; Write-Host "Installed template: $($_.Name)" } +Write-Host "Templates in ${tplDir}:"; Get-ChildItem $tplDir diff --git a/.github/actions/export-web/scripts/locate-editor.ps1 b/.github/actions/export-web/scripts/locate-editor.ps1 new file mode 100644 index 0000000..2ba7798 --- /dev/null +++ b/.github/actions/export-web/scripts/locate-editor.ps1 @@ -0,0 +1,17 @@ +# Find the Godot editor executable inside the extracted fork bundle (prefer the +# console build) and report it plus its directory for later steps. +# Outputs: godot_exe, bundle_dir + +$ErrorActionPreference = 'Stop' + +$root = "$env:RUNNER_TEMP/godot" +$exe = Get-ChildItem -Path $root -Recurse -Filter "*.exe" | + Where-Object { $_.Name -match "console" } | Select-Object -First 1 +if (-not $exe) { + $exe = Get-ChildItem -Path $root -Recurse -Filter "*.exe" | + Where-Object { $_.Name -notmatch "crash|handler" } | Select-Object -First 1 +} +if (-not $exe) { throw "Could not find a Godot editor executable in the fork zip." } +Write-Host "Editor exe: $($exe.FullName)" +"godot_exe=$($exe.FullName)" >> $env:GITHUB_OUTPUT +"bundle_dir=$($exe.Directory.FullName)" >> $env:GITHUB_OUTPUT diff --git a/.github/actions/export-web/scripts/register-nuget-source.ps1 b/.github/actions/export-web/scripts/register-nuget-source.ps1 new file mode 100644 index 0000000..e424c53 --- /dev/null +++ b/.github/actions/export-web/scripts/register-nuget-source.ps1 @@ -0,0 +1,11 @@ +# Register the fork's bundled local NuGet source, if the bundle ships one. + +$ErrorActionPreference = 'Stop' + +$nuget = Get-ChildItem -Path "$env:RUNNER_TEMP/godot" -Recurse -Directory -Filter "nuget" | Select-Object -First 1 +if ($nuget) { + Write-Host "Adding local NuGet source: $($nuget.FullName)" + dotnet nuget add source "$($nuget.FullName)" --name godot-web-fork +} else { + Write-Host "No bundled nuget folder found; relying on nuget.org." +} diff --git a/.github/actions/export-web/scripts/resolve-toolchain.ps1 b/.github/actions/export-web/scripts/resolve-toolchain.ps1 new file mode 100644 index 0000000..f1d068a --- /dev/null +++ b/.github/actions/export-web/scripts/resolve-toolchain.ps1 @@ -0,0 +1,37 @@ +# Resolve the web-export toolchain versions: read .github/web-toolchain.env (the +# single source of truth), then let non-empty action inputs override individual values. +# +# Env in: IN_FORK_REPO, IN_FORK_TAG, IN_TEMPLATE_VERSION (action inputs, may be blank) +# Outputs: fork_repo, fork_tag, template_version, asset + +$ErrorActionPreference = 'Stop' + +$path = ".github/web-toolchain.env" +if (-not (Test-Path $path)) { throw "Missing $path — the web export toolchain versions live there. See docs/WEB_EXPORT.md." } +$cfg = @{} +foreach ($line in Get-Content $path) { + if ($line -match '^\s*#' -or $line -notmatch '=') { continue } + $k, $v = $line -split '=', 2 + $cfg[$k.Trim()] = $v.Trim() +} + +function Resolve-Value($inputValue, $key) { + if (-not [string]::IsNullOrWhiteSpace($inputValue)) { + Write-Host "$key = $inputValue (overridden by workflow input)" + return $inputValue + } + if (-not $cfg.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($cfg[$key])) { + throw "$key is not set in .github/web-toolchain.env and no override was supplied." + } + Write-Host "$key = $($cfg[$key]) (from .github/web-toolchain.env)" + return $cfg[$key] +} + +$forkRepo = Resolve-Value $env:IN_FORK_REPO 'GODOT_FORK_REPO' +$forkTag = Resolve-Value $env:IN_FORK_TAG 'GODOT_FORK_TAG' +$template = Resolve-Value $env:IN_TEMPLATE_VERSION 'GODOT_TEMPLATE_VERSION' + +"fork_repo=$forkRepo" >> $env:GITHUB_OUTPUT +"fork_tag=$forkTag" >> $env:GITHUB_OUTPUT +"template_version=$template" >> $env:GITHUB_OUTPUT +"asset=Godot_v${forkTag}_mono_web_export_win64.zip" >> $env:GITHUB_OUTPUT diff --git a/.github/actions/export-web/scripts/verify-editor-checksum.ps1 b/.github/actions/export-web/scripts/verify-editor-checksum.ps1 new file mode 100644 index 0000000..ae74e32 --- /dev/null +++ b/.github/actions/export-web/scripts/verify-editor-checksum.ps1 @@ -0,0 +1,26 @@ +# Verify the editor zip against its pinned SHA-256 — a supply-chain guard for the +# third-party binary we run. Runs on both fresh downloads and cache hits, and matches +# the expected filename exactly so a restored cache can't substitute a different zip. +# +# Env in: ASSET + +$ErrorActionPreference = 'Stop' + +$zip = Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -Filter *.zip | + Where-Object { $_.Name -eq $env:ASSET } | Select-Object -First 1 +if (-not $zip) { + Write-Host "Contents of ${env:RUNNER_TEMP}/godot-zip:" + Get-ChildItem "${env:RUNNER_TEMP}/godot-zip" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $($_.Name)" } + throw "Expected editor zip '$env:ASSET' not found (cache or download failed)." +} + +$line = Get-Content .github/editor-checksums.txt | + Where-Object { $_ -notmatch '^\s*#' -and $_ -match [regex]::Escape($zip.Name) } | Select-Object -First 1 +if (-not $line) { throw "No pinned checksum for '$($zip.Name)' in .github/editor-checksums.txt — refusing to run an unverified binary. See docs/WEB_EXPORT.md." } + +$expected = (($line -split '\s+') | Where-Object { $_ })[0].ToLower() +$actual = (Get-FileHash $zip.FullName -Algorithm SHA256).Hash.ToLower() +if ($actual -ne $expected) { + throw "Checksum MISMATCH for $($zip.Name).`n expected: $expected`n actual: $actual`nThe fork release may have been re-published — verify and update .github/editor-checksums.txt." +} +Write-Host "Checksum OK for $($zip.Name): $actual"