acdream/.github/workflows/headless-portability.yml
Erik a49e92df3a fix(platform): Campaign LA LA0 review fixes — CI Linux lanes, arch doc, self-guard
Opus dual-lens review of cb6502c8 passed with six findings; this lands
the fix round:
1. headless-portability.yml: AcDream.Platform src/tests join both path
   triggers and the presentation-free build/test arrays — the moved XDG
   tests run on ubuntu-latest again (they had fallen out of every Linux
   lane).
2. acdream-architecture.md: AcDream.Platform gets its own layer block;
   Runtime may-reference clause updated (the guard changed in cb6502c8,
   its human-readable twin had not).
3. PlatformDependencyBoundaryTests: the BCL-only contract (zero
   project/package references) is now enforced, not just observed.
4. memory/project_linux_graphical.md canonical seam renamed.
5. Plan LA0 recon corrected: the K0 Headless guard was never the guard
   needing amendment (it asserts Headless own refs); Runtime own-refs
   guard was — the commit did the right thing, the plan text now says so.
6. App declares its AcDream.Platform reference explicitly per its own
   convention instead of riding transitivity.

Platform.Tests: 4 passed (3 moved + the new guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:30:04 +02:00

444 lines
19 KiB
YAML

name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
push:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:
contents: read
jobs:
portable-headless:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
# No apt step here on purpose. This job's whole claim is that the closure
# below is presentation-free: it builds Plugin.Abstractions, Core,
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
# the Headless CLI. Nothing in it opens a display, links GL, or calls
# xvfb-run, so an "install the graphical smoke dependencies" step here was
# both unnecessary and, being unconditional `sudo apt-get` in a
# two-operating-system matrix, fatal on the windows-latest leg (exit 127,
# `sudo: command not found`). The graphical jobs that do use xvfb-run and
# jq run only on ubuntu-latest and take both from the runner image.
- name: Build presentation-free closure
shell: pwsh
run: |
$projects = @(
"src/AcDream.Platform/AcDream.Platform.csproj",
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
"src/AcDream.Core/AcDream.Core.csproj",
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
"src/AcDream.Content/AcDream.Content.csproj",
"src/AcDream.Runtime/AcDream.Runtime.csproj",
"src/AcDream.Headless/AcDream.Headless.csproj"
)
foreach ($project in $projects) {
dotnet build $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# AcDream.Core.Tests still contains historical App integration fixtures,
# so it is deliberately not a member of this no-App restore lane. Core is
# built directly above and exercised through every portable downstream
# test project below; the complete solution lane retains Core.Tests.
- name: Test presentation-free closure
shell: pwsh
run: |
$projects = @(
"tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj",
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
"tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj"
)
foreach ($project in $projects) {
dotnet test $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Verify CLI without connecting
shell: pwsh
run: |
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}'
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
linux-graphical:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Build and publish Linux graphical client
shell: pwsh
run: |
dotnet build src/AcDream.App/AcDream.App.csproj -c Release -r linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet publish src/AcDream.App/AcDream.App.csproj `
-c Release `
-r linux-x64 `
--self-contained false `
-o artifacts/acdream-linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Test portable graphical boundary
shell: pwsh
run: |
dotnet test `
tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj `
-c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet test `
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
-c Release `
--filter "FullyQualifiedName~LinuxMonotonicFramePacingWaiterTests|FullyQualifiedName~LinuxPlatformBoundaryTests|FullyQualifiedName~GraphicalHostPlatformServicesTests|FullyQualifiedName~GraphicalLegacyConfigurationMigratorTests|FullyQualifiedName~GraphicalWindowBackendSelectionTests"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify Linux package contract
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
test -x "$root/AcDream.App"
test -f "$root/AcDream.App.dll"
test -f "$root/libglfw.so.3"
test -f "$root/libopenal.so"
test -f "$root/Rendering/Shaders/mesh_modern.vert"
test -f "$root/plugins/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.dll"
test -f "$root/plugins/AcDream.Plugins.Smoke/plugin.json"
test "$(grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App | wc -l)" -eq 1
grep -q 'WindowsHighResolutionFramePacingWaiter.cs' < <(
grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App)
! grep -RIn --include='*.cs' 'LocalApplicationData' \
src/AcDream.App src/AcDream.UI.Abstractions
! grep -RIn --include='*.cs' \
'WindowsHighResolutionFramePacingWaiter.Create()' \
src/AcDream.App \
--exclude='FramePacingWaiterFactory.cs' \
--exclude='WindowsHighResolutionFramePacingWaiter.cs'
# Campaign V slice V9 built this job to prove lavapipe (Mesa's software
# Vulkan) passes the capability gate: every Vulkan feature acdream requires
# is core 1.3 or a descriptor-indexing feature lavapipe implements. That made
# it the first CI job in the project's history to render a frame.
#
# Until Campaign V slice V11, linux-graphical (above) carried the mirror
# case: llvmpipe (Mesa's software OpenGL) FAILING the capability gate,
# because mandatory GL_ARB_bindless_texture has no llvmpipe implementation.
# V11 deleted the GL backend entirely, so there is no more GL capability
# gate for any driver to pass or fail — that job's "Verify actionable
# unsupported-driver gate" step went with it. "Verify the forced-unsupported
# gate exits 4" below is what now proves the exit-code-4 contract still
# fires, forcing an unsupported VULKAN feature instead.
#
# NOT DONE HERE, deliberately: a GL-versus-Vulkan pixel comparison. It was
# never viable in CI even before V11 — the probe harness renders synthetic
# verification scenes rather than the world, and the world needs retail DATs
# that CI does not have and cannot be given. The real GL-versus-Vulkan
# differential was V7's, on the developer machine, against the DATs, with
# both clocks pinned, before V11 deleted the GL arm it depended on.
linux-vulkan:
runs-on: ubuntu-latest
# Redirect the portable per-user roots into the workspace so the capability
# report, which the app writes to its own diagnostics directory rather than
# to a path a caller chooses, lands somewhere collectable. Slice L0 made
# these XDG-driven precisely so a host could place them.
env:
XDG_CONFIG_HOME: ${{ github.workspace }}/artifacts/xdg/config
XDG_DATA_HOME: ${{ github.workspace }}/artifacts/xdg/data
XDG_CACHE_HOME: ${{ github.workspace }}/artifacts/xdg/cache
VULKAN_REPORT: ${{ github.workspace }}/artifacts/xdg/cache/acdream/diagnostics/graphical-capabilities-vulkan.json
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Install lavapipe, the Vulkan loader and Xvfb
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
jq \
libvulkan1 \
mesa-vulkan-drivers \
vulkan-tools \
xauth \
xvfb
- name: Record the software Vulkan device
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
# Evidence, not configuration: no ICD is forced, because on a runner
# with no GPU lavapipe is the only one the loader can find. If that
# ever stops being true the DeviceType assertion below turns red
# rather than silently measuring different hardware, which is the
# outcome this row wants; its whole point is that the gate passes on
# the weakest conformant device in existence.
ls -l /usr/share/vulkan/icd.d/ || true
vulkaninfo --summary 2>&1 | tee artifacts/vulkaninfo-summary.txt
- name: Build and publish the Linux graphical client
shell: pwsh
run: |
dotnet publish src/AcDream.App/AcDream.App.csproj `
-c Release `
-r linux-x64 `
--self-contained false `
-o artifacts/acdream-linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Test the Vulkan backend's platform-independent decisions
shell: pwsh
run: |
dotnet test `
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
-c Release `
--filter "FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu.Vk"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# (a) + (b): one run, two gates. The harness opens a real window, runs the
# capability gate against a real device, and presents the V6c/V6d
# verification scenes through the real RHI. ACDREAM_VULKAN_PROBE_FRAMES is
# what makes it terminate: nothing in CI ever closes a window.
- name: Probe the Vulkan capability gate on lavapipe
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
out="$PWD/artifacts/vulkan-probe"
mkdir -p "$out"
# 24-bit depth explicitly: xvfb-run's default screen is 8-bit, which
# leaves the X11 WSI without a usable visual.
ACDREAM_RENDER_BACKEND=vulkan \
ACDREAM_VULKAN_PROBE=1 \
ACDREAM_VULKAN_PROBE_FRAMES=30 \
ACDREAM_AUTOMATION_ARTIFACT_DIR="$out" \
ACDREAM_DISPLAY_PROTOCOL=x11 \
ACDREAM_NO_AUDIO=1 \
xvfb-run -a -s "-screen 0 1920x1080x24" \
"$root/AcDream.App" /tmp/not-needed 2>&1 | tee artifacts/vulkan-probe.log
test -f "$VULKAN_REPORT"
cp "$VULKAN_REPORT" artifacts/vulkan-capabilities-pass.json
jq -r '"device: \(.DeviceName) (\(.DeviceType)), \(.DeviceApiVersion), \(.DriverInfo)"' \
"$VULKAN_REPORT"
# The gate accepted the device outright.
jq -e '.SupportFailures | length == 0' "$VULKAN_REPORT"
test "$(jq -r '.ActiveDisplayProtocol' "$VULKAN_REPORT")" = X11
# A software device, which is the whole point of this row.
test "$(jq -r '.DeviceType' "$VULKAN_REPORT")" = Cpu
# Vulkan 1.3 floor, unpacked from VK_MAKE_API_VERSION: major is bits
# 22+, minor is bits 12-21. Evaluate() already rejects anything lower,
# so this asserts the report agrees with the verdict rather than
# re-deriving it.
jq -e '
((.DeviceApiVersionPacked / 4194304) | floor) as $major
| (((.DeviceApiVersionPacked % 4194304) / 4096) | floor) as $minor
| $major > 1 or ($major == 1 and $minor >= 3)' "$VULKAN_REPORT"
# Advertisement is not evidence: the active probe created the device,
# built the descriptor layouts and a pipeline from committed .spv, drew
# an offscreen triangle and read the pixels back.
jq -e '.FunctionProbe.Failures | length == 0' "$VULKAN_REPORT"
jq -e '.FunctionProbe.DeviceCreation and .FunctionProbe.OffscreenReadback' \
"$VULKAN_REPORT"
- name: Assert the offline render produced a real frame
shell: bash
run: |
set -euo pipefail
png=artifacts/vulkan-probe/vulkan-bringup.png
test -f "$png"
# PNG IHDR carries the dimensions at bytes 16..23, big-endian. Reading
# them proves the capture path returned a full backbuffer rather than a
# stub, without depending on a rasterizer's pixel values.
width=$(od -An -tu4 -j16 -N4 --endian=big "$png" | tr -d ' ')
height=$(od -An -tu4 -j20 -N4 --endian=big "$png" | tr -d ' ')
bytes=$(stat -c%s "$png")
echo "captured ${width}x${height}, ${bytes} bytes"
test "$width" -ge 640
test "$height" -ge 360
# A uniform-colour frame at this size encodes to a few kilobytes. This
# threshold is the "the scene actually drew" line, and is deliberately
# a byte count rather than a pixel baseline: lavapipe and any other
# rasterizer are free to disagree about shading, and nothing in CI has
# a reference frame to disagree with.
test "$bytes" -gt 8192
# (c) The gate's failure path, exercised on a device that actually
# supports everything, so the exit-code-4 contract is proven rather than
# assumed. Same knob slice V5 built for exactly this.
- name: Verify the forced-unsupported gate exits 4
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
out="$PWD/artifacts/vulkan-forced"
mkdir -p "$out"
set +e
ACDREAM_RENDER_BACKEND=vulkan \
ACDREAM_VULKAN_PROBE=1 \
ACDREAM_VULKAN_PROBE_FRAMES=30 \
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore \
ACDREAM_AUTOMATION_ARTIFACT_DIR="$out" \
ACDREAM_DISPLAY_PROTOCOL=x11 \
ACDREAM_NO_AUDIO=1 \
xvfb-run -a -s "-screen 0 1920x1080x24" \
"$root/AcDream.App" /tmp/not-needed \
> artifacts/vulkan-forced.log 2>&1
code=$?
set -e
cat artifacts/vulkan-forced.log
test -f "$VULKAN_REPORT"
cp "$VULKAN_REPORT" artifacts/vulkan-capabilities-forced.json
test "$code" -eq 4
test "$(jq -r '.ForcedUnsupportedFeature' "$VULKAN_REPORT")" = timelineSemaphore
test "$(jq -r '.Features.TimelineSemaphore' "$VULKAN_REPORT")" = false
jq -e '.SupportFailures | length > 0' "$VULKAN_REPORT"
jq -e '.SupportFailures | any(test("timelineSemaphore"))' "$VULKAN_REPORT"
# The refusal must be actionable: the operator is told where the full
# report is, not merely that something was unsupported.
grep -q 'graphical-capabilities-vulkan.json' artifacts/vulkan-forced.log
# (d) The committed .spv are the only shaders the Vulkan backend ever
# loads. An App test already re-hashes the GLSL sources against the
# manifest, which catches "edited a shader, forgot to recompile". Nothing
# until now tied the committed BINARIES to those sources, so a stale or
# hand-edited .spv would have shipped silently. Recompiling here closes
# that, and does it on a second operating system.
- name: Verify the committed SPIR-V is fresh
shell: bash
run: |
set -euo pipefail
committed=src/AcDream.App/Rendering/Shaders/spv
fresh="$PWD/artifacts/spv-fresh"
rm -rf "$fresh"
mkdir -p "$fresh"
pwsh tools/compile-shaders.ps1 -OutputDirectory "$fresh"
# The file SET first: a .spv present in one tree and not the other is
# drift the per-file compare would never visit.
diff <(cd "$committed" && ls -1 | sort) <(cd "$fresh" && ls -1 | sort)
drift=0
for path in "$committed"/*.spv; do
name=$(basename "$path")
if ! cmp -s "$path" "$fresh/$name"; then
echo "DRIFT: $name differs from a fresh compile"
echo " committed $(sha256sum "$path" | cut -d' ' -f1) $(stat -c%s "$path") bytes"
echo " fresh $(sha256sum "$fresh/$name" | cut -d' ' -f1) $(stat -c%s "$fresh/$name") bytes"
drift=$((drift + 1))
fi
done
# The manifest is compared as JSON rather than as bytes: it is written
# with Environment.NewLine, so a byte compare would report drift for
# the operating system rather than for the shaders.
diff <(jq -S . "$committed/shaders.manifest.json") \
<(jq -S . "$fresh/shaders.manifest.json")
if [ "$drift" -ne 0 ]; then
echo "$drift .spv artifact(s) are stale."
echo "Run tools/compile-shaders.ps1 and commit the result."
exit 1
fi
echo "all committed .spv match a fresh compile"
- name: Upload Vulkan evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: linux-vulkan-evidence
if-no-files-found: warn
path: |
artifacts/vulkaninfo-summary.txt
artifacts/vulkan-probe.log
artifacts/vulkan-forced.log
artifacts/vulkan-capabilities-pass.json
artifacts/vulkan-capabilities-forced.json
artifacts/vulkan-probe/*.png