Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions .github/workflows/CD.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
name: CD

on:
# Monthly, so a new JoltPhysicsC release reaches the package without anybody asking.
# Previously dispatch-only, which is why the last publish predates the manifest.
# JoltPhysicsC says so itself, the moment it cuts a release. The cron below fires day 1 at
# 03:00 and JoltPhysicsC's porter agent runs day 2 at 05:00, so the monthly look happened 26
# hours before there was anything to find -- and cutting the release is a manual step after
# that, so a version of JoltPhysics took two months to reach this package rather than one.
repository_dispatch:
types: [upstream-released]

# Kept as a safety net rather than as the mechanism. If a dispatch is ever lost, the chain
# recovers by itself next month instead of stopping forever.
schedule:
- cron: "0 3 1 * *"
workflow_dispatch:
Expand Down Expand Up @@ -58,10 +65,46 @@ jobs:
# also what satisfies the CD's refusal to commit a bump without them.
force-publish: ${{ inputs.force-publish || false }}
publish-only: ${{ inputs.publish-only || false }}
publish-enabled: ${{ !inputs.skip-assets-publishing }}
# Never from here. Publishing moved into its own job, behind the smoke test, so no
# package reaches nuget.org without having been installed and run first. This used to be
# `!inputs.skip-assets-publishing`, and a scheduled run has no inputs to negate, so it
# evaluated true: the monthly cron published on its own, unverified, and did so exactly
# when the generated API had moved and the risk was highest.
publish-enabled: false
enable-email-notifications: true
secrets:
NUGET_UPLOAD_TOKEN: ${{ secrets.EVERGINE_NUGETORG_TOKEN }}
WAVE_SENDGRID_TOKEN: ${{ secrets.WAVE_SENDGRID_TOKEN }}
EVERGINE_EMAILREPORT_LIST: ${{ secrets.EVERGINE_EMAILREPORT_LIST }}
EVERGINE_EMAIL: ${{ secrets.EVERGINE_EMAIL }}

# Installs the package that was just built on the five runtime identifiers a runner can execute
# and drops a sphere onto a floor through it. The CD only proves the package builds -- it links
# nothing, loads nothing and calls nothing, so a package with the wrong natives, or none for an
# identifier, looks exactly like a good one until somebody installs it.
smoke:
needs: cd
if: needs.cd.outputs.packaged == 'true'
uses: ./.github/workflows/package-smoke.yml
with:
cd-run-id: ''

# Only now, and only if every leg passed. `needs: smoke` is the gate: a red leg means this job
# never starts.
publish:
needs: [cd, smoke]
if: needs.cd.outputs.packaged == 'true' && !inputs.skip-assets-publishing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/download-artifact@v4
with:
name: nuget-packages
path: nupkgs

- name: Publish to nuget.org
uses: EvergineTeam/Evergine.Bindings/.github/actions/nuget-publish@v1
with:
packages-folder: nupkgs
auth-token: ${{ secrets.EVERGINE_NUGETORG_TOKEN }}
154 changes: 154 additions & 0 deletions .github/workflows/package-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Runs the package on every runtime identifier a runner can execute, before it is published.
#
# The CD proves the package builds. This proves it works: each leg installs the .nupkg a CD run
# produced, drops a sphere onto a floor through the native library, and fails the job if the
# sphere does not fall, does not settle, or falls through.
#
# Five of the ten identifiers, and the gap is honest rather than an oversight: android-arm,
# android-arm64, ios-arm64, iossimulator-arm64 and browser-wasm ship in the package and no
# GitHub runner can execute a console application on them. They are built and never run.
name: Package smoke test

on:
# Called by the CD, where it stands between packing and publishing, and dispatchable by hand
# against any past run's artifact.
workflow_call:
inputs:
cd-run-id:
description: >-
The CD run whose nuget-packages artifact to test. Leave empty when calling from the
run that produced it, which is the normal case.
required: false
type: string
default: ''
rids:
description: 'Comma-separated runtime identifiers to run. Empty means all five.'
required: false
type: string
default: ''

workflow_dispatch:
inputs:
cd-run-id:
description: >-
The CD run whose nuget-packages artifact to test, e.g. 31220702712. Take it from a run
started with skip-assets-publishing, so nothing has been published yet.
required: true
type: string
rids:
description: >-
Comma-separated runtime identifiers to run, e.g. win-arm64. Leave empty for all five.
Retrying one identifier should not drag the four that already passed along with it.
required: false
type: string

jobs:
# The matrix is built here rather than filtered in the job below, because an `if` on a matrix
# job does not stop it queueing for a runner -- it would sit waiting for the very machine that
# is scarce, and then skip.
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
steps:
- id: pick
shell: bash
env:
WANTED: ${{ inputs.rids }}
run: |
set -euo pipefail

# Plain shell rather than jq, so the whole thing can be run on a developer machine
# before it is pushed.
ORDER="win-x64 win-arm64 linux-x64 linux-arm64 osx-arm64"
runner_for() {
case "$1" in
win-x64) echo windows-latest ;;
win-arm64) echo windows-11-arm ;;
linux-x64) echo ubuntu-latest ;;
linux-arm64) echo ubuntu-24.04-arm ;;
osx-arm64) echo macos-14 ;;
*) echo "" ;;
esac
}

selected="${WANTED:-}"
if [ -z "$selected" ]; then
selected="$ORDER"
else
selected=$(echo "$selected" | tr ',' ' ')
fi

entries=""
for rid in $selected; do
os=$(runner_for "$rid")
if [ -z "$os" ]; then
echo "::error::'$rid' is not a runtime identifier this workflow knows"
for known in $ORDER; do echo " known: $known"; done
exit 1
fi
entries="${entries}{\"rid\":\"$rid\",\"os\":\"$os\"},"
echo " $rid on $os"
done

echo "matrix=[${entries%,}]" >> "$GITHUB_OUTPUT"

desktop:
needs: prepare
name: ${{ matrix.rid }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.prepare.outputs.matrix) }}

steps:
- uses: actions/checkout@v6

- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'

# The artifact the CD produced rather than one packed here, so the thing under test is
# what would be published, byte for byte.
#
# Two ways in, because the artifact lives in a different place depending on who is asking.
# Called from the CD it belongs to this same run, and download-artifact finds it with no
# id at all. Dispatched by hand it belongs to a run that finished days ago, which only
# `gh run download` can reach.
- name: Fetch the package from this run
if: inputs.cd-run-id == ''
uses: actions/download-artifact@v4
with:
name: nuget-packages
path: DesktopSmokeTest/local-packages

- name: Fetch the package from an earlier run
if: inputs.cd-run-id != ''
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p DesktopSmokeTest/local-packages
gh run download "${{ inputs.cd-run-id }}" --repo "$GITHUB_REPOSITORY" \
-n nuget-packages -D DesktopSmokeTest/local-packages

- name: Work out which version is under test
shell: bash
run: |
set -euo pipefail
ls -la DesktopSmokeTest/local-packages/

pkg=$(ls DesktopSmokeTest/local-packages/*.nupkg | head -1)
version=$(basename "$pkg" .nupkg | sed 's/^Evergine\.Bindings\.JoltPhysics\.//')
echo "JOLT_VERSION=$version" >> "$GITHUB_ENV"
echo "testing $version on ${{ matrix.rid }}"

- name: Run the package on ${{ matrix.rid }}
shell: bash
working-directory: DesktopSmokeTest
run: |
set -euo pipefail
dotnet run -c Release -r ${{ matrix.rid }} --self-contained false \
-p:JoltPhysicsVersion="$JOLT_VERSION"
20 changes: 20 additions & 0 deletions DesktopSmokeTest/DesktopSmokeTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>disable</Nullable>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

<!--
A PackageReference, not a ProjectReference. The thing under test is the package: whether it
carries a native library for this runtime identifier and whether that library loads. A
project reference bypasses all of it and would pass against a package shipping nothing.
-->
<ItemGroup>
<PackageReference Include="Evergine.Bindings.JoltPhysics" Version="$(JoltVersion)" />
</ItemGroup>

</Project>
8 changes: 8 additions & 0 deletions DesktopSmokeTest/NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local" value="local-packages" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
142 changes: 142 additions & 0 deletions DesktopSmokeTest/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Proves that the published package works, on every desktop runtime identifier it ships.
//
// The scenario is the HelloWorld sample under Samples/, which drops a sphere onto a floor. What
// differs is what it is for: the sample references the project and prints positions, and this
// references the *package* and asserts. That distinction is the whole point -- a project
// reference bypasses the runtimes/ layout and the native library resolution, so it would pass
// against a package that ships the wrong binaries or none at all.
//
// It also has to terminate. The sample loops while the body is active, which is fine for a demo
// and unacceptable in CI: a binding that never settles would hang the job rather than fail it.

using System;
using System.Runtime.InteropServices;
using Evergine.Bindings.JoltPhysics;

internal static unsafe class Program
{
private const ushort LayerNonMoving = 0;
private const ushort LayerMoving = 1;
private const uint NumObjectLayers = 2;
private const uint NumBroadPhaseLayers = 2;

private const float DeltaTime = 1.0f / 60.0f;
private const int MaxSteps = 600; // ten seconds of simulation

private static int _failures;

private static void Check(bool condition, string what)
{
Console.WriteLine((condition ? " ok " : " FAIL ") + what);
if (!condition)
{
_failures++;
}
}

private static int Main()
{
Console.WriteLine($"JoltC on {RuntimeInformation.RuntimeIdentifier}");

// Reaching native code at all. If the package shipped no library for this identifier,
// or shipped one that cannot load, this is where it ends.
JoltPhysics.RegisterDefaultAllocator();
JoltPhysics.Init();
JoltPhysics.CreateFactory();
JoltPhysics.RegisterTypes();

IntPtr tempAllocator = JoltPhysics.TempAllocator_Create(10 * 1024 * 1024);
IntPtr jobSystem = JoltPhysics.JobSystemThreadPool_Create(2048, 8, 1);
Check(tempAllocator != IntPtr.Zero && jobSystem != IntPtr.Zero,
"the allocator and job system were created, so the native library loaded");

IntPtr broadPhase = JoltPhysics.BroadPhaseLayerInterfaceTable_Create(NumObjectLayers, NumBroadPhaseLayers);
JoltPhysics.BroadPhaseLayerInterfaceTable_MapObjectToBroadPhaseLayer(broadPhase, LayerNonMoving, 0);
JoltPhysics.BroadPhaseLayerInterfaceTable_MapObjectToBroadPhaseLayer(broadPhase, LayerMoving, 1);

IntPtr objectLayerPairFilter = JoltPhysics.ObjectLayerPairFilterTable_Create(NumObjectLayers);
JoltPhysics.ObjectLayerPairFilterTable_EnableCollision(objectLayerPairFilter, LayerNonMoving, LayerMoving);
JoltPhysics.ObjectLayerPairFilterTable_EnableCollision(objectLayerPairFilter, LayerMoving, LayerMoving);

IntPtr objectVsBroadPhaseFilter = JoltPhysics.ObjectVsBroadPhaseLayerFilterTable_Create(
broadPhase, NumBroadPhaseLayers, objectLayerPairFilter, NumObjectLayers);

IntPtr physicsSystem = JoltPhysics.PhysicsSystem_Create();
JoltPhysics.PhysicsSystem_Init(
physicsSystem, 1024, 0, 1024, 1024,
broadPhase, objectVsBroadPhaseFilter, objectLayerPairFilter);
Check(physicsSystem != IntPtr.Zero, "the physics system was created");

IntPtr bodyInterface = JoltPhysics.PhysicsSystem_GetBodyInterface(physicsSystem);

// A floor whose top surface sits at y = 0.
IntPtr floorShape = JoltPhysics.BoxShape_Create(new Vec3 { X = 100.0f, Y = 1.0f, Z = 100.0f }, 0.05f);
BodyCreationSettings floorSettings = default;
JoltPhysics.BodyCreationSettings_SetDefault(&floorSettings);
floorSettings.Position = new RVec3 { X = 0.0f, Y = -1.0f, Z = 0.0f };
floorSettings.Rotation = new Quat { X = 0f, Y = 0f, Z = 0f, W = 1f };
floorSettings.MotionType = MotionType.Static;
floorSettings.ObjectLayer = LayerNonMoving;
floorSettings.Shape = floorShape;
uint floorBodyId = JoltPhysics.BodyInterface_CreateBody(bodyInterface, &floorSettings);
JoltPhysics.BodyInterface_AddBody(bodyInterface, floorBodyId, Activation.DontActivate);

// A sphere of radius 0.5 dropped from y = 2.
IntPtr sphereShape = JoltPhysics.SphereShape_Create(0.5f);
BodyCreationSettings sphereSettings = default;
JoltPhysics.BodyCreationSettings_SetDefault(&sphereSettings);
sphereSettings.Position = new RVec3 { X = 0.0f, Y = 2.0f, Z = 0.0f };
sphereSettings.Rotation = new Quat { X = 0f, Y = 0f, Z = 0f, W = 1f };
sphereSettings.MotionType = MotionType.Dynamic;
sphereSettings.ObjectLayer = LayerMoving;
sphereSettings.Shape = sphereShape;
uint sphereBodyId = JoltPhysics.BodyInterface_CreateBody(bodyInterface, &sphereSettings);
JoltPhysics.BodyInterface_AddBody(bodyInterface, sphereBodyId, Activation.Activate);
JoltPhysics.BodyInterface_SetLinearVelocity(bodyInterface, sphereBodyId, new Vec3 { X = 0.0f, Y = -5.0f, Z = 0.0f });

JoltPhysics.PhysicsSystem_OptimizeBroadPhase(physicsSystem);

double startY = JoltPhysics.BodyInterface_GetCenterOfMassPosition(bodyInterface, sphereBodyId).Y;

int steps = 0;
while (JoltPhysics.BodyInterface_IsActive(bodyInterface, sphereBodyId) && steps < MaxSteps)
{
JoltPhysics.PhysicsSystem_Update(physicsSystem, DeltaTime, 1, tempAllocator, jobSystem);
steps++;
}

double endY = JoltPhysics.BodyInterface_GetCenterOfMassPosition(bodyInterface, sphereBodyId).Y;
Console.WriteLine($" sphere went from y={startY:F3} to y={endY:F3} in {steps} steps");

// Simulation ran rather than merely being set up. Without this the test would pass
// against a library whose Update does nothing at all.
Check(steps > 0 && steps < MaxSteps,
$"the sphere came to rest in {steps} steps, so the simulation both ran and settled");
Check(endY < startY, "the sphere fell, so gravity was integrated");

// Resting on the floor rather than through it. A sphere of radius 0.5 on a surface at
// y = 0 settles with its centre near y = 0.5, and anything below zero means collision
// did not happen -- which a build with the wrong flags can produce while still running.
Check(endY > 0.2 && endY < 0.8,
$"it settled on the floor rather than falling through it (y={endY:F3})");

JoltPhysics.BodyInterface_RemoveBody(bodyInterface, sphereBodyId);
JoltPhysics.BodyInterface_DestroyBody(bodyInterface, sphereBodyId);
JoltPhysics.BodyInterface_RemoveBody(bodyInterface, floorBodyId);
JoltPhysics.BodyInterface_DestroyBody(bodyInterface, floorBodyId);
JoltPhysics.Shape_Destroy(sphereShape);
JoltPhysics.Shape_Destroy(floorShape);
JoltPhysics.PhysicsSystem_Destroy(physicsSystem);
JoltPhysics.ObjectLayerPairFilter_Destroy(objectLayerPairFilter);
JoltPhysics.ObjectVsBroadPhaseLayerFilter_Destroy(objectVsBroadPhaseFilter);
JoltPhysics.BroadPhaseLayerInterface_Destroy(broadPhase);
JoltPhysics.JobSystem_Destroy(jobSystem);
JoltPhysics.TempAllocator_Destroy(tempAllocator);
JoltPhysics.UnregisterTypes();
JoltPhysics.DestroyFactory();
JoltPhysics.Shutdown();

Console.WriteLine(_failures == 0 ? "PASS" : $"FAIL ({_failures})");
return _failures == 0 ? 0 : 1;
}
}
Loading