diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 19bf1b34..a346a410 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -16,12 +16,16 @@ 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. + # 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 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 @@ -45,6 +49,34 @@ 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' + 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/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 9b16cbeb..e9d6a3eb 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: @@ -92,8 +93,8 @@ jobs: 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 - # 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'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 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", }, ), 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();