Files
mudler-agentandEttore Di Giacinto 33459f2e2a feat(launcher): ship an AppImage on tagged releases (#12647)
* feat(launcher): package a Linux AppImage

Ship the existing Fyne launcher as an additive release asset without
bundling the server or changing its runtime downloader.

Pin packaging tools and the type-2 runtime, preserve the caller
environment, and document the native amd64 graphics requirements.
Validate packaging contracts and wire extraction smoke checks into CI.

Assisted-by: Codex:gpt-5

* fix(launcher): harden AppImage release checks

PR builds must not receive repository write credentials. Isolate uploads
in a gated job that consumes the tested artifact without checkout.
Keep the existing release check and dependency on GoReleaser.

A surviving process does not prove that the UI event loop works.
Require observable startup progress, reject initialization errors, and
bound termination. Document the remaining limits without changing the
launcher application. Add permission and smoke failure regressions.

Assisted-by: nib:unknown

* fix(launcher): install file for AppImage builds

appimagetool fails after Go compilation when file is missing. Install
it explicitly in CI and document the local build dependency. Check for
it before compilation and cover the missing-command failure in tests.

Assisted-by: nib:unknown

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-12 17:17:43 +02:00

158 lines
8.3 KiB
Python

# SPDX-License-Identifier: MIT
import os
import re
import shutil
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
PACK = ROOT / 'contrib/appimage'
class PackagingTests(unittest.TestCase):
def test_apprun_preserves_context_and_arguments(self):
with tempfile.TemporaryDirectory(dir=ROOT) as tmp:
app = Path(tmp) / 'space AppDir'
(app / 'usr/bin').mkdir(parents=True)
(app / 'AppRun').write_bytes((PACK / 'AppRun').read_bytes())
binary = app / 'usr/bin/local-ai-launcher'
binary.write_text('#!/bin/sh\nprintf "%s\\n" "$PWD" "$HOME" "$LD_LIBRARY_PATH" "$@"\n')
binary.chmod(0o755)
env = dict(os.environ, HOME=tmp, LD_LIBRARY_PATH='/caller/libs')
result = subprocess.check_output(['sh', str(app / 'AppRun'), 'two words', '*'],
cwd=tmp, env=env, text=True)
self.assertEqual(result.splitlines(), [tmp, tmp, '/caller/libs', 'two words', '*'])
def test_unsupported_architecture_fails_before_build(self):
result = subprocess.run(['bash', str(PACK / 'build.sh')],
env=dict(os.environ, APPIMAGE_ARCH='arm64'), capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('only amd64', result.stderr)
def test_missing_file_fails_before_build(self):
with tempfile.TemporaryDirectory(dir=ROOT) as tmp:
# Expose only the utilities needed before the dependency preflight.
for command in ('dirname', 'uname'):
Path(tmp, command).symlink_to(shutil.which(command))
result = subprocess.run([shutil.which('bash'), str(PACK / 'build.sh')],
env=dict(os.environ, PATH=tmp, APPIMAGE_ARCH='amd64'),
capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Missing file command required by appimagetool', result.stderr)
for path in (ROOT / '.github/workflows/launcher-appimage.yaml', PACK / 'README.md'):
install = next(line for line in path.read_text().splitlines()
if 'apt-get install' in line)
self.assertIn('file', install.split())
def test_release_contract(self):
release = (ROOT / '.github/workflows/release.yaml').read_text()
workflow = (ROOT / '.github/workflows/launcher-appimage.yaml').read_text()
self.assertIn('needs: goreleaser', release)
self.assertIn('publish: true', release)
self.assertIn('local-ai-launcher-linux.tar.xz', release)
self.assertIn('dist/LocalAI.dmg', release)
self.assertNotIn('go-version:', release)
self.assertIn('go-version-file: go.mod', workflow)
self.assertIn('ubuntu-22.04', workflow)
self.assertIn("inputs.publish && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')", workflow)
self.assertIn('gh release view', workflow)
self.assertIn('smoke.sh', workflow)
def test_publish_permission_isolation(self):
workflow = (ROOT / '.github/workflows/launcher-appimage.yaml').read_text()
header, jobs = workflow.split('jobs:\n', 1)
self.assertIn('permissions:\n contents: read', header)
sections = re.split(r'^ ([a-z]+):\n', jobs, flags=re.MULTILINE)
jobs = dict(zip(sections[1::2], sections[2::2]))
self.assertEqual(set(jobs), {'appimage', 'publish'})
build, publish = jobs['appimage'], jobs['publish']
self.assertIn(' permissions:\n contents: read', build)
self.assertNotIn('contents: write', build)
self.assertNotIn('GH_TOKEN', build)
self.assertIn('persist-credentials: false', build)
self.assertIn('actions/upload-artifact@', build)
self.assertIn(' needs: appimage\n', publish)
self.assertIn(" if: inputs.publish && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n", publish)
self.assertIn(' permissions:\n contents: write', publish)
self.assertIn('actions/download-artifact@', publish)
self.assertIn('name: launcher-appimage-x86_64', build)
self.assertIn('name: launcher-appimage-x86_64', publish)
self.assertNotIn('checkout@', publish)
self.assertNotIn('git fetch', publish)
self.assertNotIn('gh release create', publish)
self.assertLess(publish.index('gh release view'), publish.index('gh release upload'))
# The only shell commands in the privileged job inspect/upload releases.
commands = publish.split(' run: |\n', 1)[1].strip().splitlines()
self.assertEqual(len(commands), 2)
self.assertTrue(all(line.strip().startswith('gh release ') for line in commands))
def test_smoke_startup_contract(self):
# Run the actual smoke script with fake extraction and external tools.
# No GUI, AppImage build, network or host dist directory is needed.
with tempfile.TemporaryDirectory(dir=ROOT) as tmp:
root = Path(tmp)
pack = root / 'contrib/appimage'
pack.mkdir(parents=True)
smoke = pack / 'smoke.sh'
smoke.write_bytes((PACK / 'smoke.sh').read_bytes())
tools = root / 'tools'
tools.mkdir()
dist = root / 'dist'
dist.mkdir()
def executable(path, body):
path.write_text('#!/bin/sh\nset -eu\n' + body)
path.chmod(0o755)
executable(dist / 'LocalAI-Launcher-x86_64.AppImage',
'test "$1" = --appimage-extract\n'
'mkdir -p squashfs-root/usr/bin\n'
'touch squashfs-root/local-ai-launcher.desktop squashfs-root/local-ai-launcher.png\n'
'touch squashfs-root/AppRun squashfs-root/usr/bin/local-ai-launcher\n'
'chmod +x squashfs-root/AppRun\n')
executable(tools / 'desktop-file-validate', 'exit 0\n')
executable(tools / 'ldd', 'echo "${SMOKE_LDD:-libc.so => /lib/libc.so}"\n')
executable(tools / 'timeout',
'test "$1" = --kill-after=5s\n'
'test "$2" = 15s\n'
'test "$3" = xvfb-run\n'
'mkdir -p "$HOME/.localai/models" "$HOME/.localai/backends"\n'
'printf "%s\\n" "$SMOKE_LOG"\n'
'exit "$SMOKE_STATUS"\n')
ready = 'No LocalAI installation found'
cases = [
('startup', ready, 124, True),
('silent hang', '', 124, False),
('incomplete initialization', 'Initializing launcher...', 124, False),
('initialization failure', ready + '\nFailed to initialize launcher: bad config', 124, False),
('panic', ready + '\npanic: broken', 124, False),
('GLFW failure', ready + '\nfailed to initialize GLFW', 124, False),
('early success', ready, 0, False),
('early failure', ready, 1, False),
('forced kill', ready, 137, False),
('missing dependency', ready, 124, False),
]
for name, log, status, success in cases:
with self.subTest(name=name):
env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
SMOKE_LOG=log, SMOKE_STATUS=str(status))
if name == 'missing dependency':
env['SMOKE_LDD'] = 'libGL.so => not found'
result = subprocess.run(['bash', str(smoke)], env=env,
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr)
def test_pinned_tools_and_no_library_injection(self):
script = (PACK / 'build.sh').read_text()
self.assertIn('sha256sum --check', script)
self.assertIn('--appimage-extract', script)
self.assertIn('--runtime-file', script)
self.assertNotIn('/continuous/', script)
self.assertNotIn('LD_LIBRARY_PATH=', (PACK / 'AppRun').read_text())
if __name__ == '__main__':
unittest.main()