commit 5a47cecfb84d856a800317ddc2831d0ae9f5aeed parent 6a083a14c5674fb4d23fc749e75e23df421e4263 Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 04:16:05 +0000 build: normalize custom task execution - cache only deterministic generated-output producers - disable caching explicitly for verification and host tasks - run synchronous host tools through injected Gradle execution - test cache annotations and bounded command diagnostics Diffstat:
10 files changed, 142 insertions(+), 23 deletions(-)
diff --git a/build-logic/plugins/build.gradle.kts b/build-logic/plugins/build.gradle.kts @@ -31,6 +31,7 @@ configurations[functionalTestSourceSet.runtimeOnlyConfigurationName] .extendsFrom(configurations.testRuntimeOnly.get()) dependencies { + add(functionalTestSourceSet.implementationConfigurationName, sourceSets.main.get().output) add(functionalTestSourceSet.implementationConfigurationName, gradleTestKit()) add(functionalTestSourceSet.implementationConfigurationName, kotlin("test")) } diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/TaskPolicyTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/TaskPolicyTest.kt @@ -0,0 +1,59 @@ +package org.harvestcircle.buildlogic.plugins + +import org.gradle.api.tasks.CacheableTask +import org.gradle.work.DisableCachingByDefault +import org.harvestcircle.buildlogic.plugins.tasks.GenerateCompatibilityExpectations +import org.harvestcircle.buildlogic.plugins.tasks.GenerateDesktopBuildMetadata +import org.harvestcircle.buildlogic.plugins.tasks.StageReleaseNativeLibrary +import org.harvestcircle.buildlogic.plugins.tasks.VerifyDesktopBuildMetadataArtifact +import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedCompatibilityExpectations +import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedDesktopBuildMetadata +import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsDeveloperIdSignature +import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsDistribution +import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsNotarization +import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsPackage +import org.harvestcircle.buildlogic.plugins.tasks.VerifyNativeInstallPackage +import org.harvestcircle.buildlogic.plugins.tasks.VerifyPackagedApplicationHealth +import org.harvestcircle.buildlogic.plugins.tasks.VerifyProductCoordinates +import org.harvestcircle.buildlogic.plugins.tasks.VerifyReleaseBuildProvenance +import org.harvestcircle.buildlogic.plugins.tasks.VerifyReleaseNativeLibrary +import org.harvestcircle.buildlogic.plugins.tasks.VerifySharedBoundary +import org.harvestcircle.buildlogic.plugins.tasks.VerifyTestBridgeIsolation +import org.harvestcircle.buildlogic.plugins.tasks.VerifyTestInventory +import org.harvestcircle.buildlogic.plugins.tasks.VerifyUniFfiBindings +import org.harvestcircle.buildlogic.plugins.tasks.VerifyVerificationLanes +import kotlin.test.Test +import kotlin.test.assertNotNull + +class TaskPolicyTest { + @Test + fun onlyDeterministicProducersAreCacheable() { + listOf( + GenerateDesktopBuildMetadata::class.java, + GenerateCompatibilityExpectations::class.java, + StageReleaseNativeLibrary::class.java, + ).forEach { taskType -> assertNotNull(taskType.getAnnotation(CacheableTask::class.java), taskType.name) } + + listOf( + VerifyGeneratedDesktopBuildMetadata::class.java, + VerifyProductCoordinates::class.java, + VerifyVerificationLanes::class.java, + VerifySharedBoundary::class.java, + VerifyTestInventory::class.java, + VerifyGeneratedCompatibilityExpectations::class.java, + VerifyUniFfiBindings::class.java, + VerifyReleaseNativeLibrary::class.java, + VerifyTestBridgeIsolation::class.java, + VerifyDesktopBuildMetadataArtifact::class.java, + VerifyMacOsDistribution::class.java, + VerifyMacOsPackage::class.java, + VerifyNativeInstallPackage::class.java, + VerifyPackagedApplicationHealth::class.java, + VerifyMacOsDeveloperIdSignature::class.java, + VerifyMacOsNotarization::class.java, + VerifyReleaseBuildProvenance::class.java, + ).forEach { taskType -> + assertNotNull(taskType.getAnnotation(DisableCachingByDefault::class.java), taskType.name) + } + } +} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/DesktopBuildMetadataTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/DesktopBuildMetadataTasks.kt @@ -10,6 +10,7 @@ import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault import org.harvestcircle.buildlogic.contracts.DesktopBuildMetadataValues import org.harvestcircle.buildlogic.contracts.GeneratedKotlin @@ -56,7 +57,7 @@ public abstract class GenerateDesktopBuildMetadata : DesktopBuildMetadataTask() } } -@CacheableTask +@DisableCachingByDefault(because = "Generated metadata freshness verification produces no reusable output") public abstract class VerifyGeneratedDesktopBuildMetadata : DesktopBuildMetadataTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasks.kt @@ -12,11 +12,15 @@ import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault +import java.io.ByteArrayOutputStream import java.io.File import java.util.concurrent.TimeUnit import java.util.jar.JarFile +import javax.inject.Inject +@DisableCachingByDefault(because = "Artifact metadata verification produces no reusable output") public abstract class VerifyDesktopBuildMetadataArtifact : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.NONE) @@ -38,7 +42,11 @@ public abstract class VerifyDesktopBuildMetadataArtifact : DefaultTask() { } @DisableCachingByDefault(because = "Package inspection invokes host tools") -public abstract class VerifyMacOsDistribution : DefaultTask() { +public abstract class VerifyMacOsDistribution + @Inject + constructor( + private val execOperations: ExecOperations, + ) : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) public abstract val appDirectory: DirectoryProperty @@ -90,7 +98,7 @@ public abstract class VerifyMacOsDistribution : DefaultTask() { private fun plistValue( plist: File, key: String, - ): String = commandOutput("/usr/libexec/PlistBuddy", "-c", "Print :$key", plist.absolutePath) + ): String = execOperations.commandOutput("/usr/libexec/PlistBuddy", "-c", "Print :$key", plist.absolutePath) private fun verifyPackagedNativeLibraries( app: File, @@ -107,12 +115,12 @@ public abstract class VerifyMacOsDistribution : DefaultTask() { require(machOIdentity(packaged) == machOIdentity(release)) { "Packaged native library identity does not match the Cargo release artifact" } - commandOutput("/usr/bin/codesign", "--verify", "--strict", packaged.absolutePath) + execOperations.commandOutput("/usr/bin/codesign", "--verify", "--strict", packaged.absolutePath) } } private fun machOIdentity(binary: File): String { - val output = commandOutput("/usr/bin/dwarfdump", "--uuid", binary.absolutePath) + val output = execOperations.commandOutput("/usr/bin/dwarfdump", "--uuid", binary.absolutePath) return Regex("""UUID: ([0-9A-F-]+) \(([^)]+)\)""") .find(output) ?.value @@ -120,6 +128,7 @@ public abstract class VerifyMacOsDistribution : DefaultTask() { } } +@DisableCachingByDefault(because = "Disk image verification produces no reusable output") public abstract class VerifyMacOsPackage : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) @@ -138,7 +147,11 @@ public abstract class VerifyMacOsPackage : DefaultTask() { } @DisableCachingByDefault(because = "Installation package extraction invokes host tools") -public abstract class VerifyNativeInstallPackage : DefaultTask() { +public abstract class VerifyNativeInstallPackage + @Inject + constructor( + private val execOperations: ExecOperations, + ) : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) public abstract val packageDirectory: DirectoryProperty @@ -172,9 +185,15 @@ public abstract class VerifyNativeInstallPackage : DefaultTask() { val extracted = temporaryDir.resolve("extracted").apply { mkdirs() } when (hostFamily.get()) { - "linux" -> commandOutput("dpkg-deb", "--extract", installPackage.absolutePath, extracted.absolutePath) + "linux" -> + execOperations.commandOutput( + "dpkg-deb", + "--extract", + installPackage.absolutePath, + extracted.absolutePath, + ) "windows" -> - commandOutput( + execOperations.commandOutput( "msiexec.exe", "/a", installPackage.absolutePath, @@ -235,7 +254,11 @@ public abstract class VerifyPackagedApplicationHealth : DefaultTask() { } @DisableCachingByDefault(because = "Signing verification invokes the host codesign tool") -public abstract class VerifyMacOsDeveloperIdSignature : DefaultTask() { +public abstract class VerifyMacOsDeveloperIdSignature + @Inject + constructor( + private val execOperations: ExecOperations, + ) : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) public abstract val appDirectory: DirectoryProperty @@ -243,8 +266,8 @@ public abstract class VerifyMacOsDeveloperIdSignature : DefaultTask() { @TaskAction public fun verify() { val app = appDirectory.get().asFile - commandOutput("/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", app.absolutePath) - val signature = commandOutput("/usr/bin/codesign", "--display", "--verbose=4", app.absolutePath) + execOperations.commandOutput("/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", app.absolutePath) + val signature = execOperations.commandOutput("/usr/bin/codesign", "--display", "--verbose=4", app.absolutePath) require(!signature.contains("Signature=adhoc")) { "Release application is ad-hoc signed; a Developer ID Application signature is required" } @@ -258,7 +281,11 @@ public abstract class VerifyMacOsDeveloperIdSignature : DefaultTask() { } @DisableCachingByDefault(because = "Notarization verification invokes host Apple tools") -public abstract class VerifyMacOsNotarization : DefaultTask() { +public abstract class VerifyMacOsNotarization + @Inject + constructor( + private val execOperations: ExecOperations, + ) : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.NONE) public abstract val diskImage: RegularFileProperty @@ -266,8 +293,8 @@ public abstract class VerifyMacOsNotarization : DefaultTask() { @TaskAction public fun verify() { val image = diskImage.get().asFile - commandOutput("/usr/bin/xcrun", "stapler", "validate", image.absolutePath) - commandOutput( + execOperations.commandOutput("/usr/bin/xcrun", "stapler", "validate", image.absolutePath) + execOperations.commandOutput( "/usr/sbin/spctl", "--assess", "--type", @@ -280,6 +307,7 @@ public abstract class VerifyMacOsNotarization : DefaultTask() { } } +@DisableCachingByDefault(because = "Release provenance verification produces no reusable output") public abstract class VerifyReleaseBuildProvenance : DefaultTask() { @get:Input public abstract val sourceCommit: Property<String> @@ -354,11 +382,25 @@ internal fun requireSingleCanonicalProductNativeEntry( } } -private fun commandOutput(vararg command: String): String { - val process = ProcessBuilder(*command).redirectErrorStream(true).start() - val output = process.inputStream.bufferedReader().use { it.readText() }.trim() - require(process.waitFor() == 0) { "External package inspection failed" } - return output +private fun ExecOperations.commandOutput(vararg command: String): String { + val outputBytes = ByteArrayOutputStream() + val result = + exec { spec -> + spec.commandLine(*command) + spec.standardOutput = outputBytes + spec.errorOutput = outputBytes + spec.isIgnoreExitValue = true + } + return requireSuccessfulCommand(result.exitValue, outputBytes.toString(Charsets.UTF_8)) +} + +internal fun requireSuccessfulCommand( + exitValue: Int, + output: String, +): String { + val trimmed = output.trim() + require(exitValue == 0) { "External package inspection failed with exit $exitValue: $trimmed" } + return trimmed } private fun containsSecretMaterial(output: String): Boolean = diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinatesTask.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinatesTask.kt @@ -6,10 +6,12 @@ import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline import org.harvestcircle.buildlogic.contracts.ProductCoordinates import org.harvestcircle.buildlogic.contracts.SourceProvenance +@DisableCachingByDefault(because = "Product coordinate verification produces no reusable output") abstract class VerifyProductCoordinates : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt @@ -161,7 +161,7 @@ public abstract class GenerateCompatibilityExpectations : DefaultTask() { } } -@CacheableTask +@DisableCachingByDefault(because = "Generated compatibility freshness verification produces no reusable output") public abstract class VerifyGeneratedCompatibilityExpectations : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) @@ -182,7 +182,7 @@ public abstract class VerifyGeneratedCompatibilityExpectations : DefaultTask() { } } -@CacheableTask +@DisableCachingByDefault(because = "UniFFI binding verification produces no reusable output") public abstract class VerifyUniFfiBindings : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) @@ -227,7 +227,7 @@ public abstract class StageReleaseNativeLibrary } } -@CacheableTask +@DisableCachingByDefault(because = "Release native verification produces no reusable output") public abstract class VerifyReleaseNativeLibrary : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.NONE) @@ -258,7 +258,7 @@ public abstract class VerifyReleaseNativeLibrary : DefaultTask() { } } -@CacheableTask +@DisableCachingByDefault(because = "Test bridge isolation verification produces no reusable output") public abstract class VerifyTestBridgeIsolation : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerificationLanes.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerificationLanes.kt @@ -7,6 +7,7 @@ import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault object VerificationLanes { private fun expected(environmentPrefix: String) = @@ -62,6 +63,7 @@ object VerificationLanes { } } +@DisableCachingByDefault(because = "Verification lane policy checks produce no reusable output") abstract class VerifyVerificationLanes : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifySharedBoundary.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifySharedBoundary.kt @@ -6,7 +6,9 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault(because = "Source-boundary verification produces no reusable output") abstract class VerifySharedBoundary : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifyTestInventory.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifyTestInventory.kt @@ -9,7 +9,9 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault(because = "Test inventory verification produces no reusable output") public abstract class VerifyTestInventory : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) diff --git a/build-logic/plugins/src/test/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasksTest.kt b/build-logic/plugins/src/test/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasksTest.kt @@ -1,6 +1,7 @@ package org.harvestcircle.buildlogic.plugins.tasks import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith class PackagingTasksTest { @@ -25,4 +26,11 @@ class PackagingTasksTest { requireSingleCanonicalProductNativeEntry(listOf("darwin-aarch64/libharvestcircle_test_ffi.dylib"), expected) } } + + @Test + fun hostCommandResultsPreserveOutputAndFailureDiagnostics() { + assertEquals("value", requireSuccessfulCommand(0, " value\n")) + val failure = assertFailsWith<IllegalArgumentException> { requireSuccessfulCommand(17, "tool failed\n") } + assertEquals("External package inspection failed with exit 17: tool failed", failure.message) + } }