diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b71d5a48..bc4f2e204 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,6 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle - name: Set up Android SDK uses: android-actions/setup-android@v4 @@ -37,10 +36,10 @@ jobs: run: sdkmanager "ndk;27.0.12077973" - name: Lint debug build - run: ./gradlew -PnovaAbis=x86_64 -PlintFailOnError=true lintNonRoot_gameDebug + run: ./gradlew --no-build-cache -PnovaAbis=x86_64 -PlintFailOnError=true lintNonRoot_gameDebug - name: Run JVM tests - run: ./gradlew -PnovaAbis=x86_64 testNonRoot_gameDebugUnitTest + run: ./gradlew --no-build-cache -PnovaAbis=x86_64 testNonRoot_gameDebugUnitTest - name: Run helper and onboarding tests run: python3 -m unittest tools.test_nova_retroid_smoke tools.test_native_submodule_preflight @@ -62,7 +61,6 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle - name: Set up Android SDK uses: android-actions/setup-android@v4 @@ -87,7 +85,7 @@ jobs: disk-size: 1024M emulator-boot-timeout: 900 emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot -no-metrics - script: ./gradlew -PnovaAbis=x86_64 connectedNonRoot_gameDebugAndroidTest --stacktrace + script: ./gradlew --no-build-cache -PnovaAbis=x86_64 connectedNonRoot_gameDebugAndroidTest --stacktrace build: name: Assemble release APK @@ -109,7 +107,6 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle - name: Set up Android SDK uses: android-actions/setup-android@v4 @@ -125,7 +122,7 @@ jobs: run: sdkmanager "ndk;27.0.12077973" - name: Build release APK - run: ./gradlew assembleNonRoot_gameRelease + run: ./gradlew --no-build-cache assembleNonRoot_gameRelease - name: Sign APKs if: github.event_name != 'pull_request' && env.HAS_KEYSTORE_BASE64 == 'true' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a6912cd28..69c130757 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,7 +38,6 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle - name: Set up Android SDK uses: android-actions/setup-android@v4 diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 166208a08..1f7eba1e0 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -33,6 +33,6 @@ jobs: - name: Generate and submit dependency graph uses: gradle/actions/dependency-submission@v6 with: - additional-arguments: -PnovaAbis=x86_64 --no-configuration-cache - cache-provider: basic + additional-arguments: -PnovaAbis=x86_64 --no-build-cache --no-configuration-cache + cache-disabled: true dependency-graph: generate-and-submit diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt index 403206ee7..e445fa317 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeBuildConfigurationTest.kt @@ -1,13 +1,96 @@ package com.papi.nova.ui +import java.io.StringReader import java.nio.charset.StandardCharsets import java.nio.file.Files +import java.nio.file.Path import java.nio.file.Paths +import java.util.Properties +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class NovaComposeBuildConfigurationTest { + @Test + fun vulnerableKotlinCachesRemainDisabledAcrossCi() { + val repositoryRoot = Paths.get("..").toAbsolutePath().normalize() + val gradleProperties = readText(repositoryRoot.resolve("gradle.properties")) + val workflows = Files.list(repositoryRoot.resolve(".github/workflows")).use { paths -> + paths.iterator().asSequence() + .filter { path -> + Files.isRegularFile(path) && + (path.fileName.toString().endsWith(".yml") || path.fileName.toString().endsWith(".yaml")) + } + .associate { it.fileName.toString() to readText(it) } + } + + assertPropertyDisabled( + gradleProperties, + "org.gradle.caching", + "Kotlin 2.3.21 must keep the Gradle build cache disabled" + ) + assertPropertyDisabled( + gradleProperties, + "kapt.incremental.apt", + "Kotlin 2.3.21 must keep KAPT incremental local-state deserialization disabled" + ) + + workflows.forEach { (name, workflow) -> + assertFalse( + "$name must not restore or save the Gradle User Home cache", + Regex("(?m)^\\s*cache:\\s*['\"]?gradle['\"]?\\s*(?:#.*)?$").containsMatchIn(workflow) + ) + assertFalse( + "$name must not enable Gradle's build cache on the command line", + Regex("(?:^|\\s)--build-cache(?=\\s|$)").containsMatchIn(workflow) + ) + assertFalse( + "$name must not override the fail-closed Gradle cache properties", + workflow.contains("org.gradle.caching") || workflow.contains("kapt.incremental.apt") + ) + assertFalse( + "$name must not add a Gradle cache action without extending this fail-closed contract", + Regex( + "(?i)(?:gradle/actions/setup-gradle|gradle/gradle-build-action|" + + "actions/cache(?:/restore|/save)?)@" + ).containsMatchIn(workflow) + ) + workflow.lineSequence() + .filter { Regex("(?:^|\\s)(?:\\./)?gradle(?:w)?(?:\\s|$)").containsMatchIn(it) } + .forEach { invocation -> + assertTrue( + "$name Gradle invocations must fail closed with --no-build-cache: $invocation", + invocation.contains("--no-build-cache") + ) + } + } + + val dependencySubmissionSteps = workflows.flatMap { (name, workflow) -> + actionSteps(workflow, "gradle/actions/dependency-submission").map { name to it } + } + val dependencySubmissionReferences = workflows.values.sumOf { workflow -> + Regex("(?i)gradle/actions/dependency-submission@").findAll(workflow).count() + } + assertTrue("CI must retain dependency graph submission", dependencySubmissionSteps.isNotEmpty()) + assertEquals( + "every dependency-submission reference must be a directly inspectable action step", + dependencySubmissionReferences, + dependencySubmissionSteps.size + ) + dependencySubmissionSteps.forEach { (name, step) -> + assertTrue( + "$name dependency submission must disable its Gradle User Home cache", + Regex("(?m)^\\s*cache-disabled:\\s*true\\s*$").containsMatchIn(step) + ) + assertTrue( + "$name dependency submission must pass --no-build-cache to its Gradle invocation", + Regex("(?m)^\\s*additional-arguments:.*(?:^|\\s)--no-build-cache(?:\\s|$)") + .containsMatchIn(step) + ) + } + } + @Test fun gradleEnablesComposeWithKotlinCompilerPluginAndBom() { val rootBuild = String(Files.readAllBytes(Paths.get("../build.gradle")), StandardCharsets.UTF_8) @@ -68,17 +151,66 @@ class NovaComposeBuildConfigurationTest { @Test fun buildDoesNotUseKaptForNewKotlinProcessing() { - val rootBuild = String(Files.readAllBytes(Paths.get("../build.gradle")), StandardCharsets.UTF_8) - val appBuild = String(Files.readAllBytes(Paths.get("build.gradle")), StandardCharsets.UTF_8) - val settings = String(Files.readAllBytes(Paths.get("../settings.gradle")), StandardCharsets.UTF_8) - val combinedBuildConfig = listOf(rootBuild, appBuild, settings).joinToString("\n") + val repositoryRoot = Paths.get("..").toAbsolutePath().normalize() + val buildFiles = Files.walk(repositoryRoot).use { paths -> + paths.iterator().asSequence() + .filter { path -> + Files.isRegularFile(path) && + (path.fileName.toString().endsWith(".gradle") || path.fileName.toString().endsWith(".gradle.kts")) && + repositoryRoot.relativize(path).none { segment -> + segment.toString() == ".git" || + segment.toString() == ".gradle" || + segment.toString() == "build" + } + } + .toList() + } - assertFalse( - "Prefer KSP over kapt for future Kotlin annotation processors", - combinedBuildConfig.contains("kapt") - ) + buildFiles.forEach { buildFile -> + assertFalse( + "Prefer KSP over kapt for future Kotlin annotation processors: ${repositoryRoot.relativize(buildFile)}", + readText(buildFile).contains("kapt", ignoreCase = true) + ) + } + } + + private fun readText(path: Path): String = + String(Files.readAllBytes(path), StandardCharsets.UTF_8) + + private fun assertPropertyDisabled(propertiesText: String, key: String, message: String) { + val definitionCount = Regex( + "(?m)^[ \\t\\f]*${Regex.escape(key)}(?=[ \\t\\f:=])" + ).findAll(propertiesText).count() + assertEquals("$message with one unambiguous definition", 1, definitionCount) + + val effectiveProperties = Properties().apply { + load(StringReader(propertiesText)) + } + assertEquals("$message as its effective Java Properties value", "false", effectiveProperties.getProperty(key)) } + private fun actionSteps(workflow: String, action: String): List { + val lines = workflow.lines() + return lines.indices + .filter { + Regex("(?i)^uses:\\s*['\"]?${Regex.escape(action)}@") + .containsMatchIn(lines[it].trimStart().removePrefix("- ")) + } + .map { usesIndex -> + val usesLine = lines[usesIndex] + val usesIndent = usesLine.indexOfFirst { !it.isWhitespace() }.coerceAtLeast(0) + val stepIndent = if (usesLine.trimStart().startsWith("- uses:")) usesIndent else (usesIndent - 2).coerceAtLeast(0) + var start = usesIndex + while (start > 0 && !isYamlListItem(lines[start], stepIndent)) start-- + var end = usesIndex + 1 + while (end < lines.size && !isYamlListItem(lines[end], stepIndent)) end++ + lines.subList(start, end).joinToString("\n") + } + } + + private fun isYamlListItem(line: String, indent: Int): Boolean = + line.indexOfFirst { !it.isWhitespace() } == indent && line.trimStart().startsWith("- ") + @Test fun rootTestAggregationUsesLazyTaskRegistration() { val rootBuild = String(Files.readAllBytes(Paths.get("../build.gradle")), StandardCharsets.UTF_8) diff --git a/gradle.properties b/gradle.properties index cc26c9b06..edac046e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,7 +25,10 @@ android.dependency.useConstraints=false android.r8.strictFullModeForKeepRules=false android.builtInKotlin=true -org.gradle.caching=true +# CVE-2026-53914: Kotlin 2.3.21 can unsafely deserialize KAPT incremental cache +# metadata. This cache is Gradle local state, separate from the build cache. +# Keep both paths off until a stable Kotlin 2.4.20+ toolchain is qualified. +org.gradle.caching=false +kapt.incremental.apt=false org.gradle.configuration-cache=true # android.newDsl=true -