From d29a1a2dc214d9bfa1926c95d514f4f6dacede88 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:02:19 -0500 Subject: [PATCH] ci: build and test on root self-hosted runners (#2237) --- .../droiddeck/launcher/PermissionAssumptions.kt | 16 ++++++++++++++++ .../droiddeck/launcher/files/FileOpsCopyTest.kt | 2 ++ .../droiddeck/launcher/frontend/AddedExesTest.kt | 2 ++ .../launcher/runtime/RuntimeRemovalTest.kt | 3 +++ .../launcher/runtime/RuntimeUserDataCarryTest.kt | 3 +++ .../session/EnhancedSessionLogShareTest.kt | 2 ++ tools/ci/install-ubuntu-packages.sh | 13 +++++++++---- tools/components/recipes.json | 3 +++ tools/proot/build.sh | 7 +++++++ tools/tests/test_ci_checks.py | 13 ++++++++++++- 10 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/PermissionAssumptions.kt diff --git a/app/src/test/java/com/droiddeck/launcher/PermissionAssumptions.kt b/app/src/test/java/com/droiddeck/launcher/PermissionAssumptions.kt new file mode 100644 index 000000000..18f0cc312 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/PermissionAssumptions.kt @@ -0,0 +1,16 @@ +package com.droiddeck.launcher + +import java.nio.file.Files +import org.junit.Assume.assumeTrue + +/** Root ignores file mode bits, so tests that rely on a denied read or write cannot run as root or without POSIX permissions. */ +fun assumeFilePermissionsEnforced() { + val probe = Files.createTempDirectory("permission-probe").toFile() + try { + assumeTrue("no POSIX file permissions", Files.getFileStore(probe.toPath()).supportsFileAttributeView("posix")) + assumeTrue("file permissions are not enforced for this user", probe.setReadable(false, false) && !probe.canRead()) + } finally { + probe.setReadable(true, false) + probe.delete() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt index e92ca2aec..71aa842b3 100644 --- a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.files +import com.droiddeck.launcher.assumeFilePermissionsEnforced import java.io.File import java.util.concurrent.CancellationException import java.nio.file.Files @@ -114,6 +115,7 @@ class FileOpsCopyTest { } @Test fun anOverwriteStillRefusesAReadOnlyDestination() { + assumeFilePermissionsEnforced() assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) val src = tmp.newFile("source").apply { writeText("replacement") } val dst = tmp.newFile("dest").apply { writeText("original") } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 5e8eed0c8..d8626a3bb 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.frontend +import com.droiddeck.launcher.assumeFilePermissionsEnforced import android.os.Environment import com.droiddeck.launcher.session.SessionPrefs import java.io.File @@ -308,6 +309,7 @@ class AddedExesTest { } @Test fun anExeInAFolderTheAppCannotReadIsNotAdded() { + assumeFilePermissionsEnforced() noGamesFolders() val outside = exe(File(tmp.newFolder("elsewhere"), "Game/Game.exe")) outside.parentFile!!.setReadable(false) diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt index 83d43250f..5b803dad8 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.runtime +import com.droiddeck.launcher.assumeFilePermissionsEnforced import java.io.File import java.nio.file.Files import java.nio.file.LinkOption.NOFOLLOW_LINKS @@ -128,6 +129,7 @@ class RuntimeRemovalTest { } @Test fun aPartialRemovalStaysQuarantinedAndRetryable() { + assumeFilePermissionsEnforced() val root = runtime() try { val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!! @@ -146,6 +148,7 @@ class RuntimeRemovalTest { } @Test fun reportsFailureWhenTheRuntimeCannotBeQuarantinedAndAllowsRetry() { + assumeFilePermissionsEnforced() val root = runtime() Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x")) try { diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt index 15961981c..cc1a1ad82 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.runtime +import com.droiddeck.launcher.assumeFilePermissionsEnforced import java.io.File import java.io.IOException import java.nio.file.Files @@ -155,6 +156,7 @@ class RuntimeUserDataCarryTest { } @Test fun anUnreadableAppImageDirectoryAbortsBeforeMovingHome() { + assumeFilePermissionsEnforced() assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) val old = userTree() val images = File(old, "opt/appimages") @@ -264,6 +266,7 @@ class RuntimeUserDataCarryTest { } @Test fun anInaccessibleLiveHomeIsNeverTreatedAsMissing() { + assumeFilePermissionsEnforced() assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) val root = tmp.newFolder("runtime") val staging = tmp.newFolder("runtime.new") diff --git a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt index f9fea7c9b..05ef3bdd3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import com.droiddeck.launcher.assumeFilePermissionsEnforced import android.content.Intent import org.json.JSONObject import org.junit.Assert.assertEquals @@ -403,6 +404,7 @@ class EnhancedSessionLogShareTest { } @Test fun unreadableDiscoverySubtreeKeepsOtherLogsAndMarksTheManifestPartial() { + assumeFilePermissionsEnforced() assumeFalse(System.getProperty("os.name").startsWith("Windows")) val session = tmp.newFolder("2026-11-04-12-partial-discovery") session.resolve("session.log").writeText("safe\n") diff --git a/tools/ci/install-ubuntu-packages.sh b/tools/ci/install-ubuntu-packages.sh index 1597613aa..dcccc0eb6 100644 --- a/tools/ci/install-ubuntu-packages.sh +++ b/tools/ci/install-ubuntu-packages.sh @@ -10,9 +10,13 @@ if [ "${#missing[@]}" = 0 ]; then echo "Ubuntu build dependencies are already installed." exit 0 fi -options=( - -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources - -o Dir::Etc::sourceparts=- +options=() +# Runners without the deb822 Ubuntu sources file keep their own apt configuration. +ubuntu_sources=${UBUNTU_SOURCES_FILE:-/etc/apt/sources.list.d/ubuntu.sources} +if [ -f "$ubuntu_sources" ]; then + options+=(-o "Dir::Etc::sourcelist=$ubuntu_sources" -o Dir::Etc::sourceparts=-) +fi +options+=( -o Acquire::Retries=1 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 @@ -28,13 +32,14 @@ if install_packages; then exit 0 fi echo "::warning::Runner mirror failed or timed out; retrying with the official Ubuntu mirror." +[ -f "$ubuntu_sources" ] || exit 1 sources=$(mktemp --suffix=.sources) trap 'rm -f "$sources"' EXIT mirror=https://archive.ubuntu.com/ubuntu case "$(dpkg --print-architecture)" in arm64) mirror=https://ports.ubuntu.com/ubuntu-ports ;; esac -sed -E "s@^URIs:.*@URIs: $mirror@" /etc/apt/sources.list.d/ubuntu.sources > "$sources" +sed -E "s@^URIs:.*@URIs: $mirror@" "$ubuntu_sources" > "$sources" chmod 644 "$sources" options[1]="Dir::Etc::sourcelist=$sources" install_packages diff --git a/tools/components/recipes.json b/tools/components/recipes.json index 8278e966f..b5789aa7e 100644 --- a/tools/components/recipes.json +++ b/tools/components/recipes.json @@ -139,6 +139,9 @@ "app/src/main/jniLibs/arm64-v8a/libproot.so", "app/src/main/jniLibs/arm64-v8a/libproot-loader.so" ], + "packages": [ + "gawk" + ], "ndk": true }, "comet": { diff --git a/tools/proot/build.sh b/tools/proot/build.sh index 64a51a660..6285d1878 100755 --- a/tools/proot/build.sh +++ b/tools/proot/build.sh @@ -52,6 +52,13 @@ cp "$HERE/talloc-config.h" "$WORK/talloc/config/config.h" "$TOOLCHAIN/llvm-ar" rcs "$WORK/libtalloc.a" "$WORK/talloc.o" mkdir -p "$WORK/bin" ln -s "$TOOLCHAIN/llvm-readelf" "$WORK/bin/readelf" +# loader-info.awk needs strtonum, which mawk (the default awk on some runners) lacks. +if command -v gawk >/dev/null; then + ln -s "$(command -v gawk)" "$WORK/bin/awk" +elif ! awk 'BEGIN { exit !(strtonum("0x10") == 16) }' 2>/dev/null; then + echo "gawk, or an awk with strtonum, is required to build the proot loader" >&2 + exit 1 +fi PATH="$WORK/bin:$PATH" make -C "$WORK/proot/src" -j"$(getconf _NPROCESSORS_ONLN)" \ CC="$CC" STRIP="$TOOLCHAIN/llvm-strip" OBJCOPY="$TOOLCHAIN/llvm-objcopy" OBJDUMP="$TOOLCHAIN/llvm-objdump" \ CPPFLAGS="-D_FILE_OFFSET_BITS=64 -D_GNU_SOURCE -I. -I$WORK/talloc -DARG_MAX=131072 -Wno-error=implicit-function-declaration" \ diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py index 2dc65164a..f2e258a02 100644 --- a/tools/tests/test_ci_checks.py +++ b/tools/tests/test_ci_checks.py @@ -64,7 +64,10 @@ class CiChecksTest(unittest.TestCase): query.write_text('#!/bin/sh\nexit 1\n') query.chmod(0o755) log = root / 'apt.log' - env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], APT_LOG=str(log)) + sources = root / 'ubuntu.sources' + sources.write_text('URIs: http://mirror.invalid/ubuntu\n') + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], APT_LOG=str(log), + UBUNTU_SOURCES_FILE=str(sources)) installer = ROOT / 'tools/ci/install-ubuntu-packages.sh' result = subprocess.run(['bash', str(installer), 'qemu-user'], env=dict(env, APT_RESULT='17')) self.assertEqual(result.returncode, 17) @@ -74,11 +77,19 @@ class CiChecksTest(unittest.TestCase): update, install = log.read_text().splitlines() for command in (update, install): self.assertIn('Dir::Etc::sourceparts=-', command) + self.assertIn(f'Dir::Etc::sourcelist={sources}', command) self.assertIn('Acquire::https::Timeout=15', command) self.assertIn('Acquire::Retries=1', command) self.assertIn('APT::Update::Error-Mode=any', update) self.assertIn('install -y -q --no-install-recommends qemu-user', install) + log.unlink() + absent = dict(env, UBUNTU_SOURCES_FILE=str(root / 'missing.sources'), APT_RESULT='17') + result = subprocess.run(['bash', str(installer), 'qemu-user'], env=absent) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(log.read_text().splitlines()), 1) + self.assertNotIn('Dir::Etc::sourceparts', log.read_text()) + def test_required_status_rejects_failed_cancelled_or_skipped_test_jobs(self): jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] gate = jobs['checks']