From 3e6ec8567c6aa0da8d410e6b9d894f8e325d70a9 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 13:30:05 +0000 Subject: [PATCH 01/11] CI: forge runner label is docker, not ubuntu-latest Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/cultmesh-portability.yml | 4 ++-- .github/workflows/publish-packages.yml | 4 ++-- .github/workflows/quic-native.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 19bf1b34..22619240 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -16,8 +16,8 @@ jobs: strategy: fail-fast: false matrix: - # The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR. - os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }} + # The forge runner (label `docker`) is Linux-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR. + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "docker"]' || '["docker"]') }} steps: - name: Check out CultLib diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index caa24336..f4026e41 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -50,7 +50,7 @@ jobs: cultcache-ts: name: "@gamecult/cultcache-ts to npm" if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts' - runs-on: ubuntu-latest + runs-on: docker timeout-minutes: 20 steps: # fetch-depth: 0 so the semver check in the release step can see every previously @@ -103,7 +103,7 @@ jobs: fail-fast: false matrix: package: [cultcache-py, cultnet-py, cultmesh-py] - runs-on: ubuntu-latest + runs-on: docker timeout-minutes: 20 environment: pypi permissions: diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml index 9b16cbeb..574db198 100644 --- a/.github/workflows/quic-native.yml +++ b/.github/workflows/quic-native.yml @@ -31,7 +31,7 @@ jobs: github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))) - runs-on: ubuntu-latest + runs-on: docker timeout-minutes: 5 steps: - name: Check out CultLib @@ -89,7 +89,7 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) - runs-on: ubuntu-latest + runs-on: docker # Q6 A: this pinned Debian 13 container is the only permitted Linux # release builder. The digest is the pin; the `debian:13` tag is only a # label. The artifact links against this image's glibc (2.41); a bare @@ -133,7 +133,7 @@ jobs: name: Release native binaries needs: [build-win32-x64, build-linux-x64] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v') - runs-on: ubuntu-latest + runs-on: docker timeout-minutes: 10 permissions: contents: write -- 2.47.3 From fc0f83a385663df5bcd895958b9f0176360dd048 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 13:32:41 +0000 Subject: [PATCH 02/11] CI: Windows jobs are workflow_dispatch only (no Windows runner exists) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/cultmesh-portability.yml | 2 +- .github/workflows/cultnet-interop.yml | 2 +- .github/workflows/quic-native.yml | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 22619240..b5787fd4 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - # The forge runner (label `docker`) is Linux-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR. + # The forge runner (label `docker`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the docker leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner. os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "docker"]' || '["docker"]') }} steps: diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 118b496f..8f85506e 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -1,7 +1,7 @@ name: CultNet cross-runtime interop # workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge -# runner is Ubuntu-only, so a push or pull_request trigger would queue a job that can never run. +# runner is Ubuntu-only (no Windows runner exists; push and pull_request return in the PR that registers one), so a push or pull_request trigger would queue a job that can never run. # Run it from GitHub or any Windows runner. on: workflow_dispatch: diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml index 574db198..7d4dd2be 100644 --- a/.github/workflows/quic-native.yml +++ b/.github/workflows/quic-native.yml @@ -49,11 +49,12 @@ jobs: # workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds. # A dry (workflow_dispatch) run uploads artifacts and creates no release. + # No Windows runner is registered on the forge, so this job is workflow_dispatch only + # (run it on GitHub or a Windows runner). The tag trigger returns in the PR that registers one. + # Until then a tag push skips the release job, which needs this build. build-win32-x64: name: Build win32-x64 - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) + if: github.event_name == 'workflow_dispatch' runs-on: windows-latest timeout-minutes: 20 steps: -- 2.47.3 From 041a7694759218350c14901d3f1d527d1b3b1fa4 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 13:40:39 +0000 Subject: [PATCH 03/11] CI: quic-native glibc comment no longer names ubuntu-latest as the runner Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/quic-native.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml index 7d4dd2be..eb72975c 100644 --- a/.github/workflows/quic-native.yml +++ b/.github/workflows/quic-native.yml @@ -93,8 +93,8 @@ jobs: runs-on: docker # Q6 A: this pinned Debian 13 container is the only permitted Linux # release builder. The digest is the pin; the `debian:13` tag is only a - # label. The artifact links against this image's glibc (2.41); a bare - # ubuntu-latest runner carries glibc 2.39, which is not the deploy host's + # label. The artifact links against this image's glibc (2.41); + # a bare runner (label `docker`) has no pinned glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's # OS (docs/typescript-quic-realtime-cut.md:2192-2197). container: image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 -- 2.47.3 From 5679417a826ef496ce30c4f58d74017ab79a34d1 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 9 Nov 2026 15:26:27 +0000 Subject: [PATCH 04/11] CI: back to ubuntu-latest, install PowerShell where a Linux job needs pwsh The forge has two labels: docker (node 22, py 3.11) and ubuntu-latest (act-latest, node 24, py 3.12); neither carries pwsh. Windows legs stay workflow_dispatch only. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/cultmesh-portability.yml | 21 +++++++++++++++++++-- .github/workflows/publish-packages.yml | 4 ++-- .github/workflows/quic-native.yml | 8 ++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index b5787fd4..b93aeeb0 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -16,8 +16,8 @@ jobs: strategy: fail-fast: false matrix: - # The forge runner (label `docker`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the docker leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner. - os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "docker"]' || '["docker"]') }} + # The forge runner (label `ubuntu-latest`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the ubuntu-latest leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner. + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }} steps: - name: Check out CultLib @@ -45,6 +45,23 @@ jobs: - name: Install Python CultLib packages run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py + # The forge runner images carry no pwsh; GitHub's Windows and hosted images do. + - name: Install PowerShell + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + if command -v pwsh >/dev/null; then pwsh -Version; exit 0; fi + SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo" + PWSH_VERSION=7.4.6 + curl -fsSL "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-x64.tar.gz" -o "${RUNNER_TEMP:-/tmp}/powershell.tar.gz" + $SUDO mkdir -p /opt/microsoft/powershell/7 + $SUDO tar -xzf "${RUNNER_TEMP:-/tmp}/powershell.tar.gz" -C /opt/microsoft/powershell/7 + $SUDO chmod +x /opt/microsoft/powershell/7/pwsh + $SUDO ln -sf /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh + echo "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1" >> "$GITHUB_ENV" + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 pwsh -Version + - name: Verify explicit reactive scheduling across runtimes shell: pwsh run: ./scripts/verify-reactive-document-scaling.ps1 diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index f4026e41..caa24336 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -50,7 +50,7 @@ jobs: cultcache-ts: name: "@gamecult/cultcache-ts to npm" if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts' - runs-on: docker + runs-on: ubuntu-latest timeout-minutes: 20 steps: # fetch-depth: 0 so the semver check in the release step can see every previously @@ -103,7 +103,7 @@ jobs: fail-fast: false matrix: package: [cultcache-py, cultnet-py, cultmesh-py] - runs-on: docker + runs-on: ubuntu-latest timeout-minutes: 20 environment: pypi permissions: diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml index eb72975c..e9d6a3eb 100644 --- a/.github/workflows/quic-native.yml +++ b/.github/workflows/quic-native.yml @@ -31,7 +31,7 @@ jobs: github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))) - runs-on: docker + runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check out CultLib @@ -90,11 +90,11 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) - runs-on: docker + runs-on: ubuntu-latest # Q6 A: this pinned Debian 13 container is the only permitted Linux # release builder. The digest is the pin; the `debian:13` tag is only a # label. The artifact links against this image's glibc (2.41); - # a bare runner (label `docker`) has no pinned glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's + # a bare runner's OS image has its own glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's # OS (docs/typescript-quic-realtime-cut.md:2192-2197). container: image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 @@ -134,7 +134,7 @@ jobs: name: Release native binaries needs: [build-win32-x64, build-linux-x64] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v') - runs-on: docker + runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write -- 2.47.3 From a04014e0b45c3bb9c0055153248a8c93175e1482 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 9 Nov 2026 15:50:27 +0000 Subject: [PATCH 05/11] ci: rerun 1 (RUDP flake evidence) Co-Authored-By: Claude Sonnet 5.5 -- 2.47.3 From c9cdbc7edc35c095a1ba16a3779fa338bf3173de Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 9 Nov 2026 16:29:33 +0000 Subject: [PATCH 06/11] ci: rerun 2 (RUDP flake evidence) Co-Authored-By: Claude Sonnet 5.5 -- 2.47.3 From 073efd06e79716f7fdabe44a83cebbbafc9596af Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 9 Nov 2026 16:53:27 +0000 Subject: [PATCH 07/11] ci: rerun 3 (RUDP flake evidence) Co-Authored-By: Claude Sonnet 5.5 -- 2.47.3 From a56ae56b7d2ac21b6049274e04c0d650a5dee600 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 19:50:50 +0000 Subject: [PATCH 08/11] CI: install the Rust toolchain on forge Linux runners The act-latest image has no cargo; the cross-runtime CultCache store tests build the Rust interop example and failed with "cargo: not found" once the RUDP flake and pwsh were out of the way. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/cultmesh-portability.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index b93aeeb0..b327b8c5 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -45,6 +45,17 @@ jobs: - name: Install Python CultLib packages run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py + # The forge runner images carry no cargo either (the cross-runtime store tests build the Rust interop example); GitHub's hosted images do. + - name: Install Rust toolchain + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + if ! command -v cargo >/dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then + curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable + fi + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # The forge runner images carry no pwsh; GitHub's Windows and hosted images do. - name: Install PowerShell if: runner.os == 'Linux' -- 2.47.3 From ef82811f2e41826aeb6115f8e9d075ffc4d56a96 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 20:03:46 +0000 Subject: [PATCH 09/11] CI: fetch tags in cultmesh-portability checkout scripts/test-declared-dependency-ranges.mjs asserts the clone has release tags; actions/checkout fetches none by default. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/cultmesh-portability.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index b327b8c5..a346a410 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -22,6 +22,10 @@ jobs: steps: - name: Check out CultLib uses: actions/checkout@v4 + with: + # The declared-dependency-range test builds the admitted releases from their tags. + fetch-depth: 0 + fetch-tags: true - name: Set up .NET uses: actions/setup-dotnet@v4 -- 2.47.3 From a2bb0bced471c8243ffbde3226d9a9c6b796a70c Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 20:19:03 +0000 Subject: [PATCH 10/11] cultnet-ts test: wait for loopback datagrams instead of a fixed 100 ms sleep "server mode admits a pinned client that restarts on a new port" asserted the Accept arrived after sleep(100) and failed on the forge runner (ok(accept) undefined, run 894). It now polls for the packet or frame it asserts on, up to 2 s; no assertion changed. Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet-ts/test/rudp-connect-generation.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cultnet-ts/test/rudp-connect-generation.test.ts b/packages/cultnet-ts/test/rudp-connect-generation.test.ts index cea61a6d..883fe638 100644 --- a/packages/cultnet-ts/test/rudp-connect-generation.test.ts +++ b/packages/cultnet-ts/test/rudp-connect-generation.test.ts @@ -367,6 +367,10 @@ async function bind(): Promise { return socket; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// Wait for a datagram-driven condition rather than guessing how long loopback delivery takes on a loaded runner. +const until = async (condition: () => boolean, limitMs = 2_000) => { + for (let waited = 0; !condition() && waited < limitMs; waited += 10) await sleep(10); +}; test("server mode admits a pinned client that restarts on a new port", async () => { const serverSocket = await bind(); @@ -385,24 +389,24 @@ test("server mode admits a pinned client that restarts on a new port", async () try { const first = session(1); a.send(encodeRudpPacket(first.createConnect(0, enc("join"))), port, "127.0.0.1"); - await sleep(100); + await until(() => atA.some((packet) => packet.packetType === "accept")); const accept = atA.find((packet) => packet.packetType === "accept"); assert.ok(accept); first.receive(accept, 0); a.send(encodeRudpPacket(first.createAckForReceived(accept.sequence)), port, "127.0.0.1"); a.send(encodeRudpPacket(send(first, "hello")), port, "127.0.0.1"); - await sleep(100); + await until(() => frames.length === 1); assert.deepEqual(frames, ["hello"]); const second = session(1); // the process restarted on a new port, pinned b.send(encodeRudpPacket(second.createConnect(0, enc("join"))), port, "127.0.0.1"); - await sleep(100); + await until(() => atB.some((packet) => packet.packetType === "accept")); const acceptB = atB.find((packet) => packet.packetType === "accept"); assert.ok(acceptB, `the restarted client on a new port was not admitted: ${JSON.stringify(atB.map((packet) => packet.packetType))}`); second.receive(acceptB, 1); assert.ok(second.connected); b.send(encodeRudpPacket(send(second, "after")), port, "127.0.0.1"); - await sleep(100); + await until(() => frames.length === 2); assert.deepEqual(frames, ["hello", "after"]); } finally { server.close(); -- 2.47.3 From 356687b7174393c5d524bdfa39dd5ffff099f9af Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 9 Nov 2026 22:01:25 +0000 Subject: [PATCH 11/11] cultmesh-ts test: result window outlasts the transport ack in the RUDP catalog publish test The put's flush is bounded by the result window, so a 25 ms flush and 100 ms window made a slow ack on a loaded forge runner surface as local_send_failed instead of the window_elapsed the test checks. Drop the flush override and give the window room. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/test/cultmesh.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 68429969..10de21b9 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -2986,8 +2986,9 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () maxFragmentBytes: 1024, maxPendingReliablePackets: 16, connectTimeoutMs: 1_000, - flushTimeoutMs: 25, - resultWindowMs: 100, + // The flush is bounded by the result window, so the window must outlast the transport ack on a loaded + // runner: a short one turns a slow ack into local_send_failed, which is not the path this test checks. + resultWindowMs: 750, sourceRole: "test-provider", }, ), -- 2.47.3