Skip to content
Open
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
220 changes: 220 additions & 0 deletions .github/workflows/distribution-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
name: Distribution checks

on:
pull_request:
paths:
- '.github/workflows/distribution-checks.yml'
- 'tools/check_*'
- 'tools/fetch_release_package.py'
- 'tests/test_packaged_game.py'
workflow_dispatch:
inputs:
source_commit:
description: Commit used to build the release
required: true
type: string

permissions:
contents: read

concurrency:
group: distribution-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_COMMIT: ${{ inputs.source_commit || github.event.pull_request.base.sha || github.sha }}
ALSOFT_DRIVERS: 'null'

jobs:
linux:
name: Linux ${{ matrix.kind }} binary
runs-on: ubuntu-22.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- kind: AppImage
asset: 'Light-Engine-{version}-x86_64.AppImage'
- kind: portable
asset: 'Light-Engine-{version}-linux-x86_64.tar.gz'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install native GUI test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq xvfb xdotool libgl1 libopengl0 libegl1 libasound2
python3 -m pip install Pillow==11.3.0 python-xlib==0.33
- name: Download and verify the built release asset
env:
ASSET: ${{ matrix.asset }}
run: python3 tools/fetch_release_package.py "$ASSET" .ci/package --commit "$SOURCE_COMMIT"
- name: Verify shipped source and run the packaged binary
env:
PACKAGE_KIND: ${{ matrix.kind }}
APPIMAGE_EXTRACT_AND_RUN: '1'
run: |
set -euo pipefail
if [ "$PACKAGE_KIND" = AppImage ]; then
chmod +x .ci/package
(cd .ci && ./package --appimage-extract > extraction.log)
GAME=.ci/squashfs-root/game.love
LAUNCHER="$PWD/.ci/package"
else
tar -xzf .ci/package -C .ci
GAME=$(find .ci -name game.love -print -quit)
LAUNCHER="$PWD/$(dirname "$GAME")/light-engine.sh"
fi
python3 tools/check_packaged_game.py "$GAME"
xvfb-run -a python3 tools/check_desktop_binary.py \
--output "test-results/distribution-linux-$PACKAGE_KIND" "$LAUNCHER"
- name: Upload native screenshots and diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: distribution-linux-${{ matrix.kind }}
path: test-results/
retention-days: 14

windows:
name: Windows ${{ matrix.arch }} binary
runs-on: windows-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
arch: [x64, x86]
env:
GALLIUM_DRIVER: llvmpipe
LIBGL_ALWAYS_SOFTWARE: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install screenshot support
run: python -m pip install Pillow==11.3.0
- name: Download and verify the built release asset
env:
PACKAGE_ARCH: ${{ matrix.arch }}
run: python tools/fetch_release_package.py "Light-Engine-{version}-windows-$env:PACKAGE_ARCH.zip" .ci/package.zip --commit "$env:SOURCE_COMMIT"
- name: Extract and verify the fused game executable
run: |
python -c "import zipfile; zipfile.ZipFile('.ci/package.zip').extractall('.ci/distribution')"
python tools/check_packaged_game.py ".ci/distribution/Light Engine/Light Engine.exe"
- name: Install a software OpenGL renderer
env:
PACKAGE_ARCH: ${{ matrix.arch }}
run: |
Invoke-WebRequest https://github.com/pal1000/mesa-dist-win/releases/download/25.0.7/mesa3d-25.0.7-release-msvc.7z -OutFile .ci/mesa.7z
7z x -y .ci/mesa.7z "-o.ci/mesa"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$Mesa = Get-ChildItem .ci/mesa -Recurse -Filter opengl32.dll | Where-Object { $_.Directory.Name -eq $env:PACKAGE_ARCH } | Select-Object -First 1
if (-not $Mesa) { throw "Mesa architecture missing: $env:PACKAGE_ARCH" }
Copy-Item (Join-Path $Mesa.Directory.FullName '*.dll') '.ci/distribution/Light Engine'
"SDL_OPENGL_LIBRARY=$((Resolve-Path '.ci/distribution/Light Engine/opengl32.dll').Path)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Run the packaged game through native input
env:
PACKAGE_ARCH: ${{ matrix.arch }}
run: python tools/check_desktop_binary.py --output "test-results/distribution-windows-$env:PACKAGE_ARCH" ".ci/distribution/Light Engine/Light Engine.exe"
- name: Exercise the real desktop companion windows
if: matrix.arch == 'x64'
run: |
python -m pip install -r separate-addons/screen-overlay/desktop/requirements.txt
python -m unittest discover -s separate-addons/screen-overlay/desktop/tests -p test_gui.py
- name: Upload native screenshots and diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: distribution-windows-${{ matrix.arch }}
path: test-results/
retention-days: 14

android:
name: Android ${{ matrix.kind }} signed APK
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- kind: universal
suffix: android
abi: arm64-v8a
- kind: arm64
suffix: android-arm64-v8a
abi: arm64-v8a
- kind: armv7
suffix: android-armeabi-v7a
abi: armeabi-v7a
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Download and verify the signed release APK
env:
APK_SUFFIX: ${{ matrix.suffix }}
run: |
python3 tools/fetch_release_package.py "Light-Engine-{version}-$APK_SUFFIX.apk" .ci/game.apk --commit "$SOURCE_COMMIT"
python3 tools/check_packaged_game.py .ci/game.apk --android
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run the ARM release using Android native translation
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
arch: x86_64
profile: pixel_2
emulator-boot-timeout: 600
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot -camera-back none -memory 4096
script: python3 tools/check_android_apk.py .ci/game.apk --abi ${{ matrix.abi }} --output test-results/distribution-android-${{ matrix.kind }}
- name: Upload emulator screenshots and logcat
if: always()
uses: actions/upload-artifact@v4
with:
name: distribution-android-${{ matrix.kind }}
path: test-results/
retention-days: 14

android-overlay:
name: Android 15 overlay companion APK
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Download and verify the signed companion APK
run: python3 tools/fetch_release_package.py Light-Engine-screen-overlay-android.apk .ci/overlay.apk --commit "$SOURCE_COMMIT"
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Exercise consent and native overlay windows
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel_2
emulator-boot-timeout: 600
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot -camera-back none -memory 4096
script: python3 tools/check_android_overlay.py .ci/overlay.apk --output test-results/distribution-android-overlay
- name: Upload companion screenshots and logcat
if: always()
uses: actions/upload-artifact@v4
with:
name: distribution-android-overlay
path: test-results/
retention-days: 14
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
tag: ${{ steps.meta.outputs.tag }}
release_name: ${{ steps.meta.outputs.release_name }}
android_version_code: ${{ steps.meta.outputs.android_version_code }}
build_release: ${{ steps.published.outputs.build_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -92,7 +93,16 @@ jobs:
print(f"Release {version}; Android versionCode {code}")
PYCODE

- name: Check whether this version is already published
id: published
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
python3 tools/release_state.py --repository "$GITHUB_REPOSITORY" --tag "$TAG"

- name: Build release notes from CHANGELOG.md
if: steps.published.outputs.build_release == 'true'
run: |
set -euo pipefail
VERSION="${{ steps.meta.outputs.version }}"
Expand Down Expand Up @@ -122,6 +132,7 @@ jobs:
cat notes.md

- name: Generate icons from art/logo.png
if: steps.published.outputs.build_release == 'true'
run: |
set -euo pipefail
if command -v magick >/dev/null 2>&1; then
Expand All @@ -147,9 +158,11 @@ jobs:
find inputs -type f | sort

- name: Package the optional addon separately
if: steps.published.outputs.build_release == 'true'
run: python3 tools/make_addon_release.py Light-Engine-screen-overlay-addon.zip

- name: Open draft release
if: steps.published.outputs.build_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
Expand All @@ -167,6 +180,7 @@ jobs:
gh release upload "$TAG" Light-Engine-screen-overlay-addon.zip --clobber

- name: Upload build inputs
if: steps.published.outputs.build_release == 'true'
uses: actions/upload-artifact@v4
with:
name: build-inputs
Expand All @@ -177,6 +191,7 @@ jobs:
build-windows:
name: Windows ${{ matrix.arch }}
needs: prepare
if: needs.prepare.outputs.build_release == 'true'
runs-on: windows-latest
strategy:
fail-fast: false
Expand Down Expand Up @@ -317,6 +332,7 @@ jobs:
build-linux:
name: Linux ${{ matrix.kind }}
needs: prepare
if: needs.prepare.outputs.build_release == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down Expand Up @@ -455,6 +471,7 @@ jobs:
build-android:
name: Android
needs: prepare
if: needs.prepare.outputs.build_release == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
66 changes: 66 additions & 0 deletions tests/test_packaged_game.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Check shipped bytes for ZIP, fused executable and direct APK asset layouts."""
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile

TOOLS = Path(__file__).resolve().parents[1] / 'tools'
sys.path.insert(0, str(TOOLS))
spec = importlib.util.spec_from_file_location('check_packaged_game', TOOLS / 'check_packaged_game.py')
checker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(checker)


class PackagedGameTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
(self.root / 'main.lua').write_bytes(b'function love.load() end\n')
(self.root / 'assets').mkdir()
(self.root / 'assets/pixel.png').write_bytes(b'original\x00bytes')

def tearDown(self):
self.temp.cleanup()

def verify(self, package, android=False):
with patch.object(checker, 'ROOT', str(self.root)), \
patch.object(checker, 'collect_files', return_value=['main.lua', 'assets/pixel.png']):
checker.verify(package, android)

def package(self, name, prefix='', changed=False, junk=False):
path = self.root / name
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr(prefix + 'main.lua', (self.root / 'main.lua').read_bytes())
archive.writestr(prefix + 'assets/pixel.png', b'changed' if changed else (self.root / 'assets/pixel.png').read_bytes())
if junk:
archive.writestr(prefix + '__pycache__/cached.pyc', b'cache')
return path

def test_zip_fused_executable_and_apk_preserve_runtime_bytes(self):
archive = self.package('game.love')
self.verify(archive)
fused = self.root / 'game.exe'
fused.write_bytes(b'MZ' + b'\x00' * 126 + archive.read_bytes())
self.verify(fused)
self.verify(self.package('game.apk', prefix='assets/'), android=True)

def test_changed_assets_are_rejected(self):
with self.assertRaisesRegex(ValueError, 'different bytes for assets/pixel.png'):
self.verify(self.package('changed.love', changed=True))

def test_development_cache_is_rejected(self):
with self.assertRaisesRegex(ValueError, 'development files'):
self.verify(self.package('dirty.love', junk=True))

def test_android_aapt_excludes_editor_directories(self):
apk = self.package('android.love', prefix='assets/')
with patch.object(checker, 'ROOT', str(self.root)), \
patch.object(checker, 'collect_files', return_value=['main.lua', 'assets/pixel.png', 'loxel/.vscode/settings.json']):
checker.verify(apk, android=True)


if __name__ == '__main__':
unittest.main()
33 changes: 33 additions & 0 deletions tests/test_release_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Published packages must survive failed or ambiguous GitHub lookups."""
import importlib.util
from pathlib import Path
import unittest

ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('release_state', ROOT / 'tools/release_state.py')
release_state = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release_state)


class ReleaseStateTests(unittest.TestCase):
def test_published_release_cannot_be_rebuilt(self):
self.assertFalse(release_state.should_build(0, 'HTTP/2.0 200 OK\n\n{"draft":false}'))

def test_draft_can_resume_and_confirmed_absence_can_build(self):
self.assertTrue(release_state.should_build(0, 'HTTP/2.0 200 OK\r\n\r\n{"draft":true}'))
self.assertTrue(release_state.should_build(1, 'HTTP/2.0 404 Not Found\n\n{"message":"Not Found"}'))

def test_api_and_network_errors_cannot_overwrite_a_release(self):
for response in ('', 'HTTP/2.0 401 Unauthorized\n\n{}',
'HTTP/2.0 403 Forbidden\n\n{}', 'HTTP/2.0 503 Service Unavailable\n\n{}'):
with self.subTest(response=response), self.assertRaises(RuntimeError):
release_state.should_build(1, response)

def test_incomplete_metadata_is_not_permission_to_build(self):
for body in ('{}', '{"draft":null}', '{"draft":"false"}', '<html>proxy error</html>'):
with self.subTest(body=body), self.assertRaises(RuntimeError):
release_state.should_build(0, 'HTTP/2.0 200 OK\n\n' + body)


if __name__ == '__main__':
unittest.main()
Loading
Loading