M4: Linux + Windows CI build pipeline (.github/workflows/build.yml)

The literal thing nothing on this dev machine can verify: whether the
build actually runs on Windows at all. Solved by having real CI do it —
ubuntu-latest and windows-latest both build the native Box3D/miniaudio
shims from source via CMake, build and test the full .NET solution, stage
a shippable configuration (engine.windowing/assets/render/input/physics/
audio + physics-demo-game — engine.editor deliberately excluded, that's
M4's actual "done when"), and then run it headless against samples/
PhysicsDemo for 200 frames, asserting DemoBox settled at y≈1.0 in the
resulting dump. Real physics, real scene load, real plugin loading,
proven on both platforms, not just built.

Required restructuring the native Content items into per-OS ItemGroups
(native/linux-x64/ vs native/win-x64/, selected via
$([MSBuild]::IsOSPlatform(...))): linux-x64's .so is committed (built and
verified here); win-x64's .dll is never committed — nothing here can
build or run one to verify — and only ever exists as something the
Windows job produces fresh, in-place, right before `dotnet build`.

Caught one real bug dry-running this exact staging locally before
trusting it to a workflow run: PluginHost.Load calls Assembly.
LoadFromAssemblyPath, which throws on a relative path — the verification
step's `--plugins ../../plugins` failed immediately with "is not an
absolute path." Every manual verification earlier this session happened
to always pass an absolute --plugins path, which is exactly why this
never surfaced before. Fixed by resolving to an absolute path before
invoking Engine.Host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 17:52:28 +03:00
co-authored by Claude Sonnet 5
parent 2d0168104d
commit faabfc2cb4
8 changed files with 187 additions and 25 deletions
+123
View File
@@ -0,0 +1,123 @@
name: Build
# M4's "done when": the build runs on both platforms with no editor
# plugins in the shipped binary. Neither half of that is verifiable from
# a single Linux dev machine — this workflow is what actually proves it,
# real builds on real ubuntu-latest and windows-latest runners, not just
# a cross-platform-looking csproj and hope.
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
rid: linux-x64
- os: windows-latest
rid: win-x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Build native physics shim (Box3D, pinned commit — see native/physics-native/CMakeLists.txt)
shell: bash
run: |
cmake -S native/physics-native -B native/physics-native/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/physics-native/build --config Release
- name: Build native audio shim (miniaudio)
shell: bash
run: |
cmake -S native/audio-native -B native/audio-native/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/audio-native/build --config Release
# linux-x64's .so is committed (built and verified on the dev
# machine); win-x64's .dll can only ever be produced here, since
# nothing else in this project can build or run one to check it —
# landing it at exactly the path Engine.Physics.csproj/Engine.Audio.
# csproj's win-x64 Content item expects.
- name: Place win-x64 native libraries
if: matrix.rid == 'win-x64'
shell: bash
run: |
mkdir -p plugins/engine.physics/Engine.Physics/native/win-x64
mkdir -p plugins/engine.audio/Engine.Audio/native/win-x64
find native/physics-native/build -iname "lingua_physics.dll" -exec cp {} plugins/engine.physics/Engine.Physics/native/win-x64/lingua_physics.dll \;
find native/audio-native/build -iname "lingua_audio.dll" -exec cp {} plugins/engine.audio/Engine.Audio/native/win-x64/lingua_audio.dll \;
test -f plugins/engine.physics/Engine.Physics/native/win-x64/lingua_physics.dll
test -f plugins/engine.audio/Engine.Audio/native/win-x64/lingua_audio.dll
- name: Build
run: dotnet build LinguaEngine.sln -c Release
# Nothing in the suite opens a real GL window or a real audio device
# (Engine.Audio.Tests forces miniaudio's null backend specifically so
# this works) — the whole thing runs headless on both runners.
- name: Test
run: dotnet test LinguaEngine.sln -c Release --no-build
- name: Stage the shippable build (engine.editor deliberately excluded)
shell: bash
run: |
set -euo pipefail
DIST="dist/${{ matrix.rid }}"
STAGE="$DIST/plugins"
mkdir -p "$STAGE"
for id in engine.windowing engine.assets engine.render engine.input engine.physics engine.audio; do
impl="$(awk -F. '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)}}1' OFS=. <<<"$id")"
src="plugins/$id/$impl/bin/Release/net9.0"
mkdir -p "$STAGE/$id"
cp -r "$src/." "$STAGE/$id/"
cp "plugins/$id/plugin.json" "$STAGE/$id/"
done
mkdir -p "$DIST/engine"
cp -r src/Engine.Host/bin/Release/net9.0/. "$DIST/engine/"
GAME_DST="$DIST/samples/PhysicsDemo/GamePlugins/physics-demo-game"
mkdir -p "$GAME_DST"
cp -r samples/PhysicsDemo/GamePlugins/physics-demo-game/bin/Release/net9.0/. "$GAME_DST/"
cp samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json "$GAME_DST/"
cp -r samples/PhysicsDemo/assets "$DIST/samples/PhysicsDemo/assets"
cp samples/PhysicsDemo/project.json samples/PhysicsDemo/scene.json "$DIST/samples/PhysicsDemo/"
- name: Verify the shippable build actually runs (headless, real physics + real scene load)
shell: bash
run: |
set -euo pipefail
# Absolute paths only: Assembly.LoadFromAssemblyPath (which
# PluginHost.Load uses) throws on a relative one — found by
# hand running this exact staging locally before trusting it to
# a workflow run at all.
DIST="$(pwd)/dist/${{ matrix.rid }}"
cd "$DIST/samples/PhysicsDemo"
dotnet "$DIST/engine/Engine.Host.dll" run --headless \
--plugins "$DIST/plugins" --project project.json --scene scene.json \
--frames 200 --dump dump.json
cat dump.json
python3 -c "
import json
data = json.load(open('dump.json'))
box = next(o for o in data if o['name'] == 'DemoBox')
y = box['transform']['position'][1]
assert 0.9 < y < 1.1, f'DemoBox should have settled near y=1.0, got y={y}'
print(f'DemoBox settled at y={y:.4f} — physics ran for real on ${{ matrix.rid }}')
"
- uses: actions/upload-artifact@v4
with:
name: lingua-engine-${{ matrix.rid }}
path: dist/${{ matrix.rid }}
+6
View File
@@ -39,3 +39,9 @@ native/*/.fetchcontent-cache/
## the .csproj, and the .cs files there stay tracked. ## the .csproj, and the .cs files there stay tracked.
samples/*/GamePlugins/*/*.dll samples/*/GamePlugins/*/*.dll
samples/*/GamePlugins/*/*.pdb samples/*/GamePlugins/*/*.pdb
## Windows builds of the native physics/audio shims — nothing here can
## build or verify one (no Windows machine), so unlike linux-x64's .so,
## win-x64's .dll is never committed. .github/workflows/build.yml's
## Windows job builds it fresh into this exact path before `dotnet build`.
**/native/win-x64/
@@ -4,17 +4,22 @@
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <!-- Same reasoning as engine.physics' own native Content item — see
<!-- Same reasoning as engine.physics' own native Content item: default there for why linux-x64 is committed and win-x64 isn't. -->
DllImport probing already checks the calling assembly's directory, <ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
so this is copied straight alongside Engine.Audio.dll rather than <Content Include="native/linux-x64/liblingua_audio.so">
through a runtimes/<rid>/native/ path. -->
<Content Include="native/liblingua_audio.so">
<Link>liblingua_audio.so</Link> <Link>liblingua_audio.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="native/win-x64/lingua_audio.dll">
<Link>lingua_audio.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" /> <ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<ProjectReference Include="..\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj" /> <ProjectReference Include="..\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj" />
@@ -7,7 +7,6 @@
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- The compiled native shim (Box3D folded in) — see <!-- The compiled native shim (Box3D folded in) — see
native/physics-native/. Copied straight into this plugin's own native/physics-native/. Copied straight into this plugin's own
output directory (not a runtimes/<rid>/native/ NuGet-style path): output directory (not a runtimes/<rid>/native/ NuGet-style path):
@@ -16,13 +15,27 @@
LoadUnmanagedDll falls back to that default probing whenever its LoadUnmanagedDll falls back to that default probing whenever its
AssemblyDependencyResolver doesn't recognize the name (returning AssemblyDependencyResolver doesn't recognize the name (returning
IntPtr.Zero, not failing outright) — confirmed empirically, see IntPtr.Zero, not failing outright) — confirmed empirically, see
PhysicsWorldTests. --> PhysicsWorldTests.
<Content Include="native/liblingua_physics.so">
Picked by the BUILDING machine's OS, not cross-compiled: linux-x64's
.so is committed (built and verified here); win-x64's .dll is never
committed — nothing here can build or run one to verify — and is
instead produced fresh by .github/workflows/build.yml's Windows job
right before `dotnet build` runs, landing at exactly this path. -->
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Content Include="native/linux-x64/liblingua_physics.so">
<Link>liblingua_physics.so</Link> <Link>liblingua_physics.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="native/win-x64/lingua_physics.dll">
<Link>lingua_physics.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" /> <ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<ProjectReference Include="..\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj" /> <ProjectReference Include="..\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj" />
@@ -15,11 +15,21 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Content Include="../../plugins/engine.audio/Engine.Audio/native/liblingua_audio.so"> <Content Include="../../plugins/engine.audio/Engine.Audio/native/linux-x64/liblingua_audio.so">
<Link>liblingua_audio.so</Link> <Link>liblingua_audio.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="../../plugins/engine.audio/Engine.Audio/native/win-x64/lingua_audio.dll">
<Link>lingua_audio.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="assets/test_tone.wav"> <Content Include="assets/test_tone.wav">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
@@ -15,17 +15,22 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <!-- Same native shim Engine.Physics itself ships, same per-OS split —
<!-- Same native shim Engine.Physics itself ships — see that project's see that project's own Content items. -->
own Content item for why it's copied directly (not under a <ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
runtimes/<rid>/native/ path) rather than referenced some other <Content Include="../../plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so">
way. -->
<Content Include="../../plugins/engine.physics/Engine.Physics/native/liblingua_physics.so">
<Link>liblingua_physics.so</Link> <Link>liblingua_physics.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="../../plugins/engine.physics/Engine.Physics/native/win-x64/lingua_physics.dll">
<Link>lingua_physics.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\plugins\engine.physics\Engine.Physics\Engine.Physics.csproj" /> <ProjectReference Include="..\..\plugins\engine.physics\Engine.Physics\Engine.Physics.csproj" />
</ItemGroup> </ItemGroup>