commit 63449f5d59ddb49f84498915b3ce8d41ce36f15b parent 92cf3a757805df1f215df36791b1d827b5367618 Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 22:06:18 +0000 build: add the desktop packaging convention plugin - migrate package formats, metadata, native inspection, and readiness into typed build logic - add a bounded packaged health entry with isolated storage and redacted evidence - distinguish macOS signed identity checks from Linux and Windows byte checks - prove standalone and governed current-host package lanes with negative fixtures Diffstat:
13 files changed, 812 insertions(+), 573 deletions(-)
diff --git a/Makefile b/Makefile @@ -5,10 +5,10 @@ CARGO ?= cargo CARGO_MANIFEST := core/Cargo.toml EXTBUILD ?= $(if $(shell cargo extbuild --version 2>/dev/null),cargo extbuild run --) -.PHONY: help doctor lock metadata build-logic-check format format-fix lint test check build bindings dev run audit licenses foundation-check package source-check package-check signing-check notarization-check release-check clean +.PHONY: help doctor lock metadata build-logic-check format format-fix lint test check build bindings dev run audit licenses foundation-check package host-package-check governed-package-check source-check package-check signing-check notarization-check release-check clean help: - @printf '%s\n' doctor lock metadata build-logic-check format format-fix lint test check build bindings dev run audit licenses foundation-check package source-check package-check signing-check notarization-check release-check clean + @printf '%s\n' doctor lock metadata build-logic-check format format-fix lint test check build bindings dev run audit licenses foundation-check package host-package-check governed-package-check source-check package-check signing-check notarization-check release-check clean doctor: $(if $(strip $(EXTBUILD)),cargo extbuild doctor,@:) @@ -72,6 +72,19 @@ foundation-check: doctor package: check $(EXTBUILD) $(GRADLE) --no-daemon :app:desktop:verifyHostPackage +host-package-check: + java -version + $(CARGO) --version + $(GRADLE) --version + $(GRADLE) --no-daemon :app:desktop:verifyHostPackage + +governed-package-check: + cargo extbuild doctor + cargo extbuild run -- java -version + cargo extbuild run -- $(CARGO) --version + cargo extbuild run -- $(GRADLE) --version + cargo extbuild run -- $(GRADLE) --no-daemon :app:desktop:verifyHostPackage + source-check: check bindings licenses $(EXTBUILD) $(GRADLE) --no-daemon :app:desktop:sourceReadiness diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -1,560 +1,5 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -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.jvm.tasks.Jar -import org.harvestcircle.buildlogic.plugins.HarvestCircleRustFfiExtension -import org.harvestcircle.gradle.FfiCompatibilityBaseline -import org.harvestcircle.gradle.ProductCoordinates -import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import java.io.File -import java.util.jar.JarFile - plugins { id("org.harvestcircle.build.desktop-app") id("org.harvestcircle.build.rust-ffi") -} - -val rustManifest = - rootProject.layout.projectDirectory - .file("core/Cargo.toml") - .asFile - -fun workspacePackageValue(key: String): String { - val workspacePackage = - rustManifest - .readText() - .substringAfter("[workspace.package]", missingDelimiterValue = "") - .substringBefore("\n[") - require(workspacePackage.isNotBlank()) { "Cargo manifest is missing [workspace.package]" } - val expression = Regex("""(?m)^${Regex.escape(key)}\s*=\s*"([^"]+)"\s*$""") - return expression - .find(workspacePackage) - ?.groupValues - ?.get(1) - ?: throw GradleException("Cargo workspace package metadata is missing $key") -} - -val productCoordinatesFile = - rootProject.layout.projectDirectory.file("config/product/harvestcircle-v1.properties") -val productCoordinates = - ProductCoordinates.parse(providers.fileContents(productCoordinatesFile).asText.get()) -val ffiCompatibilityBaselineFile = - rootProject.layout.projectDirectory.file("core/compatibility/harvestcircle-ffi-v4.properties") -val ffiCompatibilityBaseline = - FfiCompatibilityBaseline.load(ffiCompatibilityBaselineFile.asFile) -val appVersion = workspacePackageValue("version") -val macOsBuildVersion = "1" -check(ffiCompatibilityBaseline["product.version"] == appVersion) { - "FFI compatibility product version must match the Cargo workspace version" -} -check(ffiCompatibilityBaseline["product.coordinate_digest"] == productCoordinates.digest) { - "FFI compatibility product-coordinate digest is stale" -} -val installableVersion = ffiCompatibilityBaseline["package.version"] -check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { - "Package version must satisfy the macOS jpackage contract" -} -val applicationName = productCoordinates["product.name"] -val productSlug = productCoordinates["product.slug"] -val bundleId = productCoordinates["desktop.bundle_id"] -val copyrightNotice = productCoordinates["copyright.notice"] -val vendorName = productCoordinates["vendor.name"] -val rustFfi = extensions.getByType<HarvestCircleRustFfiExtension>() -val nativeOsName = rustFfi.nativeOsName.get() -val isMacOsHost = nativeOsName.lowercase().startsWith("mac") -val isLinuxHost = nativeOsName.lowercase().startsWith("linux") -val isWindowsHost = nativeOsName.lowercase().startsWith("windows") -val rustLibraryName = rustFfi.libraryName.get() -val rustDebugLibrary = rustFfi.debugLibrary.get().asFile -val rustReleaseLibrary = rustFfi.releaseLibrary.get().asFile -val jnaPlatformPrefix = rustFfi.jnaPlatformPrefix.get() -val buildSourceCommit = rustFfi.sourceCommit -val buildSourceDirty = rustFfi.sourceDirty -val buildRadrootsRevision = rustFfi.radrootsRevision -val buildSourceDateEpoch = rustFfi.sourceDateEpoch -val releaseNativeResourcesJar = tasks.named<Jar>("releaseNativeResourcesJar") -val releaseNativeRuntimeJar = - releaseNativeResourcesJar - .get() - .archiveFile - .get() - .asFile - -abstract class VerifyDesktopBuildMetadataArtifact : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val desktopJar: RegularFileProperty - - @get:Input - abstract val expectedBuildEvidence: ListProperty<String> - - @TaskAction - fun verify() { - JarFile(desktopJar.get().asFile).use { jar -> - val entry = - jar.getJarEntry("org/harvestcircle/application/generated/DesktopBuildMetadata.class") - ?: throw GradleException("Desktop build metadata is missing from the application artifact") - val metadata = jar.getInputStream(entry).use { it.readBytes() }.toString(Charsets.ISO_8859_1) - expectedBuildEvidence.get().forEach { evidence -> - require(metadata.contains(evidence)) { - "Desktop application artifact is missing generated build evidence" - } - } - } - } -} -val verifyDesktopBuildMetadataArtifact by tasks.registering(VerifyDesktopBuildMetadataArtifact::class) { - dependsOn("jar") - desktopJar.set(tasks.named<Jar>("jar").flatMap { it.archiveFile }) - expectedBuildEvidence.set( - listOf( - appVersion, - installableVersion, - gradle.gradleVersion, - System.getProperty("java.version"), - libs.versions.kotlin.get(), - libs.versions.compose.get(), - ), - ) -} - -abstract class VerifyMacOsDistribution : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val appDirectory: DirectoryProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val releaseLibrary: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val iconSource: RegularFileProperty - - @get:Input - abstract val expectedBundleId: Property<String> - - @get:Input - abstract val expectedPackageVersion: Property<String> - - @get:Input - abstract val expectedBuildVersion: Property<String> - - @get:Input - abstract val expectedNativeEntry: Property<String> - - @TaskAction - fun verify() { - val app = appDirectory.get().asFile - val plist = app.resolve("Contents/Info.plist") - require(plist.isFile) { "Packaged macOS Info.plist is missing" } - require(plistValue(plist, "CFBundleIdentifier") == expectedBundleId.get()) { - "Packaged macOS bundle identifier is incorrect" - } - require(plistValue(plist, "CFBundleShortVersionString") == expectedPackageVersion.get()) { - "Packaged macOS version is incorrect" - } - require(plistValue(plist, "CFBundleVersion") == expectedBuildVersion.get()) { - "Packaged macOS build version is incorrect" - } - - val sourceIcon = iconSource.get().asFile.readBytes() - val matchingIcons = - app - .walkTopDown() - .filter { it.isFile && it.extension == "icns" } - .count { it.readBytes().contentEquals(sourceIcon) } - require(matchingIcons == 1) { "Packaged macOS icon does not match the canonical icon" } - - val expectedEntry = expectedNativeEntry.get() - val packagedLibraries = mutableListOf<ByteArray>() - app - .walkTopDown() - .filter { it.isFile && it.extension == "jar" } - .forEach { jarFile -> - JarFile(jarFile).use { jar -> - jar.getJarEntry(expectedEntry)?.let { entry -> - packagedLibraries += jar.getInputStream(entry).use { it.readBytes() } - } - } - } - require(packagedLibraries.size == 1) { - "Packaged application must contain exactly one release native library" - } - val release = releaseLibrary.get().asFile - val packaged = temporaryDir.resolve(release.name).apply { writeBytes(packagedLibraries.single()) } - require(machOIdentity(packaged) == machOIdentity(release)) { - "Packaged native library identity does not match the Cargo release artifact" - } - commandOutput("/usr/bin/codesign", "--verify", "--strict", packaged.absolutePath) - } - - private fun plistValue( - plist: File, - key: String, - ): String = commandOutput("/usr/libexec/PlistBuddy", "-c", "Print :$key", plist.absolutePath) - - private fun machOIdentity(binary: File): String { - val output = commandOutput("/usr/bin/dwarfdump", "--uuid", binary.absolutePath) - return Regex("""UUID: ([0-9A-F-]+) \(([^)]+)\)""") - .find(output) - ?.value - ?: throw GradleException("Could not read the packaged native library identity") - } - - 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: $output" } - return output - } -} - -abstract class VerifyMacOsPackage : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val packageDirectory: DirectoryProperty - - @get:Input - abstract val expectedFileName: Property<String> - - @TaskAction - fun verify() { - val packages = packageDirectory.asFileTree.files.filter { it.isFile && it.extension == "dmg" } - require(packages.size == 1) { "Expected exactly one macOS disk image" } - require(packages.single().name == expectedFileName.get()) { "Unexpected macOS disk image name" } - require(packages.single().length() > 0L) { "Packaged macOS disk image is empty" } - } -} - -abstract class VerifyNativeInstallPackage : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val packageDirectory: DirectoryProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val releaseLibrary: RegularFileProperty - - @get:Input - abstract val packageExtension: Property<String> - - @get:Input - abstract val expectedVersion: Property<String> - - @get:Input - abstract val expectedNativeEntry: Property<String> - - @get:Input - abstract val hostFamily: Property<String> - - @TaskAction - fun verify() { - val extension = packageExtension.get() - val packages = packageDirectory.asFileTree.files.filter { it.isFile && it.extension == extension } - require(packages.size == 1) { "Expected exactly one .$extension installation package" } - val installPackage = packages.single() - require(installPackage.name.contains(expectedVersion.get())) { - "Installation package name does not contain the governed version" - } - require(installPackage.length() > 0L) { "Installation package is empty" } - - val extracted = temporaryDir.resolve("extracted").apply { mkdirs() } - when (hostFamily.get()) { - "linux" -> commandOutput("dpkg-deb", "--extract", installPackage.absolutePath, extracted.absolutePath) - "windows" -> - commandOutput( - "msiexec.exe", - "/a", - installPackage.absolutePath, - "/qn", - "TARGETDIR=${extracted.absolutePath}", - ) - else -> throw GradleException("Unsupported native package host") - } - - val expectedEntry = expectedNativeEntry.get() - val packagedLibraries = mutableListOf<ByteArray>() - extracted - .walkTopDown() - .filter { it.isFile && it.extension == "jar" } - .forEach { jarFile -> - JarFile(jarFile).use { jar -> - jar.getJarEntry(expectedEntry)?.let { entry -> - packagedLibraries += jar.getInputStream(entry).use { it.readBytes() } - } - } - } - require(packagedLibraries.size == 1) { - "Installation package must contain exactly one canonical native library" - } - require(packagedLibraries.single().contentEquals(releaseLibrary.get().asFile.readBytes())) { - "Installed native library does not match the canonical Cargo release artifact" - } - } - - private fun commandOutput(vararg command: String) { - val process = - ProcessBuilder(*command) - .redirectErrorStream(true) - .start() - val output = - process.inputStream - .bufferedReader() - .use { it.readText() } - .trim() - require(process.waitFor() == 0) { "Installation package extraction failed: $output" } - } -} - -abstract class VerifyMacOsDeveloperIdSignature : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val appDirectory: DirectoryProperty - - @TaskAction - 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) - require(!signature.contains("Signature=adhoc")) { - "Release application is ad-hoc signed; a Developer ID Application signature is required" - } - require(signature.lineSequence().any { it.startsWith("Authority=Developer ID Application:") }) { - "Release application is not signed by a Developer ID Application identity" - } - require( - signature.lineSequence().any { - it.startsWith("TeamIdentifier=") && it != "TeamIdentifier=not set" - }, - ) { - "Release application signature has no Apple team identifier" - } - } - - 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) { "Code-signature verification failed: $output" } - return output - } -} - -abstract class VerifyMacOsNotarization : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val diskImage: RegularFileProperty - - @TaskAction - fun verify() { - val image = diskImage.get().asFile - commandOutput("/usr/bin/xcrun", "stapler", "validate", image.absolutePath) - commandOutput( - "/usr/sbin/spctl", - "--assess", - "--type", - "open", - "--context", - "context:primary-signature", - "--verbose=2", - image.absolutePath, - ) - } - - 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) { "Notarization verification failed: $output" } - return output - } -} - -tasks.named("check") { - dependsOn(verifyDesktopBuildMetadataArtifact) -} - -compose.desktop { - application { - disableDefaultConfiguration() - dependsOn(releaseNativeResourcesJar.get()) - dependsOn("jar") - val desktopJar = tasks.named<Jar>("jar").flatMap { it.archiveFile } - mainJar.set(desktopJar) - fromFiles(desktopJar, configurations.runtimeClasspath, releaseNativeRuntimeJar) - if (isMacOsHost) { - jvmArgs += - listOf( - "-Dapple.awt.application.name=$applicationName", - "-Dapple.awt.application.appearance=system", - ) - } - - nativeDistributions { - targetFormats( - when { - isMacOsHost -> TargetFormat.Dmg - isLinuxHost -> TargetFormat.Deb - isWindowsHost -> TargetFormat.Msi - else -> throw GradleException("Unsupported desktop package host: $nativeOsName") - }, - ) - - packageName = applicationName - packageVersion = installableVersion - description = "$applicationName $appVersion" - copyright = copyrightNotice - vendor = vendorName - - macOS { - bundleID = bundleId - iconFile.set(project.file("src/main/resources/icons/$productSlug.icns")) - packageName = applicationName - dockName = applicationName - packageBuildVersion = macOsBuildVersion - } - } - } -} - -val verifyMacOsDistribution by tasks.registering(VerifyMacOsDistribution::class) { - dependsOn("createDistributable") - appDirectory.set(layout.buildDirectory.dir("compose/binaries/main/app/$applicationName.app")) - releaseLibrary.set(rustReleaseLibrary) - iconSource.set(layout.projectDirectory.file("src/main/resources/icons/$productSlug.icns")) - expectedBundleId.set(bundleId) - expectedPackageVersion.set(installableVersion) - expectedBuildVersion.set(macOsBuildVersion) - expectedNativeEntry.set("$jnaPlatformPrefix/$rustLibraryName") -} -tasks.matching { it.name == "createDistributable" }.configureEach { - dependsOn(releaseNativeResourcesJar) -} -val verifyMacOsPackage by tasks.registering(VerifyMacOsPackage::class) { - dependsOn("packageDmg", verifyMacOsDistribution) - packageDirectory.set(layout.buildDirectory.dir("compose/binaries/main/dmg")) - expectedFileName.set("$applicationName-$installableVersion.dmg") -} -val verifyLinuxPackage by tasks.registering(VerifyNativeInstallPackage::class) { - dependsOn("packageDeb", "verifyReleaseNativeLibrary") - packageDirectory.set(layout.buildDirectory.dir("compose/binaries/main/deb")) - releaseLibrary.set(rustReleaseLibrary) - packageExtension.set("deb") - expectedVersion.set(installableVersion) - expectedNativeEntry.set("$jnaPlatformPrefix/$rustLibraryName") - hostFamily.set("linux") -} -val verifyWindowsPackage by tasks.registering(VerifyNativeInstallPackage::class) { - dependsOn("packageMsi", "verifyReleaseNativeLibrary") - packageDirectory.set(layout.buildDirectory.dir("compose/binaries/main/msi")) - releaseLibrary.set(rustReleaseLibrary) - packageExtension.set("msi") - expectedVersion.set(installableVersion) - expectedNativeEntry.set("$jnaPlatformPrefix/$rustLibraryName") - hostFamily.set("windows") -} -val verifyHostPackage by tasks.registering { - when { - isMacOsHost -> dependsOn(verifyMacOsPackage) - isLinuxHost -> dependsOn(verifyLinuxPackage) - isWindowsHost -> dependsOn(verifyWindowsPackage) - else -> throw GradleException("Unsupported desktop package host: $nativeOsName") - } -} -val verifyMacOsDeveloperIdSignature by tasks.registering(VerifyMacOsDeveloperIdSignature::class) { - dependsOn(verifyMacOsPackage) - appDirectory.set(layout.buildDirectory.dir("compose/binaries/main/app/$applicationName.app")) -} -val verifyMacOsNotarization by tasks.registering(VerifyMacOsNotarization::class) { - dependsOn(verifyMacOsPackage) - diskImage.set(layout.buildDirectory.file("compose/binaries/main/dmg/$applicationName-$installableVersion.dmg")) -} - -abstract class VerifyReleaseBuildProvenance : DefaultTask() { - @get:Input - abstract val sourceCommit: Property<String> - - @get:Input - abstract val sourceDirty: Property<String> - - @get:Input - abstract val radrootsRevision: Property<String> - - @get:Input - abstract val sourceDateEpoch: Property<String> - - @TaskAction - fun verify() { - require(Regex("[0-9a-f]{40}").matches(sourceCommit.get())) { - "Release source commit provenance is unknown or malformed" - } - require(sourceDirty.get() == "false") { "Release provenance reports a dirty or unknown source tree" } - require(Regex("[0-9a-f]{40}").matches(radrootsRevision.get())) { - "Release Radroots revision provenance is unknown or malformed" - } - require(sourceDateEpoch.get().toULongOrNull()?.let { it > 0UL } == true) { - "Release SOURCE_DATE_EPOCH provenance is unknown or malformed" - } - } -} -val verifyReleaseBuildProvenance by tasks.registering(VerifyReleaseBuildProvenance::class) { - sourceCommit.set(buildSourceCommit) - sourceDirty.set(buildSourceDirty) - radrootsRevision.set(buildRadrootsRevision) - sourceDateEpoch.set(buildSourceDateEpoch) -} -val sourceReadiness by tasks.registering { - dependsOn( - ":verifyProductCoordinates", - ":verifyVerificationLanes", - ":verifyFoundationBoundaries", - ":verifyFoundationArchive", - ":app:shared:check", - "check", - "verifyUniFfiBindings", - ) -} -val packageReadiness by tasks.registering { - dependsOn(verifyHostPackage, verifyReleaseBuildProvenance, verifyDesktopBuildMetadataArtifact) -} -val signingReadiness by tasks.registering { - dependsOn(verifyMacOsDeveloperIdSignature) -} -val notarizationReadiness by tasks.registering { - dependsOn(verifyMacOsNotarization) -} -tasks.register("releaseReadiness") { - dependsOn("checkLicense", "dependencyCheckAnalyze", sourceReadiness, packageReadiness) - if (isMacOsHost) { - dependsOn(signingReadiness, notarizationReadiness) - } + id("org.harvestcircle.build.packaging") } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -9,24 +9,42 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.harvestcircle.application.ApplicationLifecycle import org.harvestcircle.application.HarvestCircleApplication +import org.harvestcircle.application.NativeHarvestCircleRuntime +import org.harvestcircle.application.verifyNativeCompatibility +import org.harvestcircle.ffi.HarvestCircleException +import org.harvestcircle.ffi.compatibilityDescriptor import org.harvestcircle.identities.ui.StartupFailureScreen import java.awt.Dimension import java.awt.Taskbar +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import javax.imageio.ImageIO +import kotlin.system.exitProcess private const val APPLICATION_NAME = "HarvestCircle" internal const val INITIAL_WINDOW_WIDTH = 1280 internal const val INITIAL_WINDOW_HEIGHT = 800 internal const val MINIMUM_WINDOW_WIDTH = 1100 internal const val MINIMUM_WINDOW_HEIGHT = 720 +internal const val HEALTH_CHECK_ARGUMENT = "--health-check" +internal const val HEALTH_READY_EVIDENCE = "HARVESTCIRCLE_HEALTH_READY" +internal const val HEALTH_CLOSED_EVIDENCE = "HARVESTCIRCLE_HEALTH_CLOSED" +private const val HEALTH_FAILURE_EVIDENCE = "HARVESTCIRCLE_HEALTH_FAILED" +private const val HEALTH_TIMEOUT_MILLIS = 90_000L private val isMacOs: Boolean = System .getProperty("os.name", "") .startsWith("Mac", ignoreCase = true) -fun main() { +fun main(args: Array<String>) { + if (isHealthCheck(args)) exitProcess(runHealthCheck()) + val nativeStartupProblem = if (isMacOs) configureMacOsApplication() else null application { @@ -67,6 +85,59 @@ fun main() { } } +internal fun isHealthCheck(args: Array<String>): Boolean = args.size == 1 && args.single() == HEALTH_CHECK_ARGUMENT + +private fun runHealthCheck(): Int { + val executor = Executors.newSingleThreadExecutor() + return try { + executor + .submit<Int> { runBlocking { executeHealthCheck() } } + .get(HEALTH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + System.err.println("$HEALTH_FAILURE_EVIDENCE:TIMEOUT") + 1 + } finally { + executor.shutdownNow() + } +} + +private suspend fun executeHealthCheck(): Int { + var runtime: NativeHarvestCircleRuntime? = null + var closed = false + var stage = "OPEN" + try { + withTimeout(HEALTH_TIMEOUT_MILLIS) { + stage = "COMPATIBILITY" + verifyNativeCompatibility(compatibilityDescriptor()) + stage = "OPEN" + runtime = NativeHarvestCircleRuntime.open(developmentMode = true) + stage = "BOOTSTRAP" + val snapshot = requireNotNull(runtime).bootstrap() + stage = "READY" + require(snapshot.lifecycle in setOf(ApplicationLifecycle.Ready, ApplicationLifecycle.Degraded)) + println(HEALTH_READY_EVIDENCE) + stage = "SHUTDOWN" + val receipt = requireNotNull(runtime).shutdown() + require(receipt.closed) + closed = true + println(HEALTH_CLOSED_EVIDENCE) + } + return 0 + } catch (error: HarvestCircleException.Failure) { + System.err.println("$HEALTH_FAILURE_EVIDENCE:$stage:${error.code}:${error.safeMessage}") + return 1 + } catch (error: Exception) { + System.err.println("$HEALTH_FAILURE_EVIDENCE:$stage:${error.javaClass.simpleName}") + return 1 + } finally { + if (!closed) { + runCatching { + withTimeout(HEALTH_TIMEOUT_MILLIS) { runtime?.shutdown() } + } + } + } +} + private fun configureMacOsApplication(): String? { System.setProperty("apple.awt.application.name", APPLICATION_NAME) System.setProperty("apple.awt.application.appearance", "system") diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt @@ -34,10 +34,11 @@ class ProductCoordinateConsumerTest { "app/desktop/build.gradle.kts", "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt", "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt", + "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt", ).joinToString("\n") { relativePath -> root.resolve(relativePath).readText() } - assertTrue(build.contains("ProductCoordinates.parse")) + assertTrue(build.contains("ProductCoordinates.load")) assertTrue(build.contains("application.mainClass = mainClass")) - assertTrue(build.contains("bundleID = bundleId")) + assertTrue(build.contains("mac.bundleID = bundleId")) assertTrue(build.contains("expectedPackage.set(productCoordinates[\"ffi.kotlin_package\"])")) assertFalse(Files.exists(root.resolve("core/compatibility/v5-baseline.properties"))) } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt @@ -3,7 +3,9 @@ package org.harvestcircle.desktop import java.io.ByteArrayInputStream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class MainTest { @Test @@ -19,4 +21,11 @@ class MainTest { assertNull(loadRuntimeIcon { null }) assertNull(loadRuntimeIcon { ByteArrayInputStream("not an image".encodeToByteArray()) }) } + + @Test + fun healthCheckEntryRequiresTheSingleSupportedArgument() { + assertTrue(isHealthCheck(arrayOf(HEALTH_CHECK_ARGUMENT))) + assertFalse(isHealthCheck(emptyArray())) + assertFalse(isHealthCheck(arrayOf(HEALTH_CHECK_ARGUMENT, "unexpected"))) + } } diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -28,6 +28,7 @@ class ConventionPluginSmokeTest { setOf( "org.harvestcircle.build.desktop-app", "org.harvestcircle.build.rust-ffi", + "org.harvestcircle.build.packaging", ) fixture.resolve("settings.gradle.kts").writeText( buildString { @@ -52,6 +53,12 @@ class ConventionPluginSmokeTest { "org.harvestcircle.build.kmp-shared" -> kmpPlugins "org.harvestcircle.build.rust-ffi" -> "id(\"org.harvestcircle.build.desktop-app\")\nid(\"$pluginId\")" + "org.harvestcircle.build.packaging" -> + """ + id("org.harvestcircle.build.desktop-app") + id("org.harvestcircle.build.rust-ffi") + id("$pluginId") + """.trimIndent() else -> "id(\"$pluginId\")" } buildFile.writeText("plugins { $pluginBlock }\n") @@ -273,6 +280,56 @@ class ConventionPluginSmokeTest { } } + @Test + fun packagingPluginLaunchesAndClosesThePackagedHealthEntry() { + val fixture = createTempDirectory("harvestcircle-package-health-") + preparePackagingBuild(fixture, "printf 'HARVESTCIRCLE_HEALTH_READY\\nHARVESTCIRCLE_HEALTH_CLOSED\\n'") + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:verifyPackagedApplicationHealth", + "-x", + ":app:desktop:createDistributable", + "--stacktrace", + ) + .build() + + assertTrue(result.output.contains("BUILD SUCCESSFUL"), result.output) + } + + @Test + fun packagingPluginRejectsMissingCloseTimeoutAndSecretOutput() { + listOf( + Triple("close", "printf 'HARVESTCIRCLE_HEALTH_READY\\n'", "did not report closed health evidence"), + Triple("timeout", "sleep 5", "health-check timed out"), + Triple( + "redaction", + "printf 'nsec1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HARVESTCIRCLE_HEALTH_READY HARVESTCIRCLE_HEALTH_CLOSED\\n'", + "emitted secret material", + ), + ).forEach { (caseName, scriptBody, expected) -> + val fixture = createTempDirectory("harvestcircle-package-$caseName-") + preparePackagingBuild(fixture, scriptBody, timeoutSeconds = if (caseName == "timeout") 1L else 10L) + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:verifyPackagedApplicationHealth", + "-x", + ":app:desktop:createDistributable", + "--stacktrace", + ) + .buildAndFail() + + assertTrue(result.output.contains(expected), result.output) + } + } + private fun prepareDesktopBuild( fixture: java.nio.file.Path, withUnitTest: Boolean, @@ -309,6 +366,33 @@ class ConventionPluginSmokeTest { ) } + private fun preparePackagingBuild( + fixture: java.nio.file.Path, + scriptBody: String, + timeoutSeconds: Long = 10L, + ) { + prepareDesktopBuild(fixture, withUnitTest = true) + val executable = fixture.resolve("app/desktop/fixture-health.sh") + executable.writeText("#!/bin/sh\n$scriptBody\n") + check(executable.toFile().setExecutable(true)) + fixture.resolve("app/desktop/build.gradle.kts").writeText( + """ + plugins { + id("org.harvestcircle.build.desktop-app") + id("org.harvestcircle.build.rust-ffi") + id("org.harvestcircle.build.packaging") + } + + tasks.named<org.harvestcircle.buildlogic.plugins.tasks.VerifyPackagedApplicationHealth>( + "verifyPackagedApplicationHealth", + ) { + executable.set(layout.projectDirectory.file("fixture-health.sh")) + timeoutSeconds.set(${timeoutSeconds}L) + } + """.trimIndent() + "\n", + ) + } + private val kmpCatalog = """ [versions] diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt @@ -1,8 +0,0 @@ -package org.harvestcircle.buildlogic.plugins - -import org.gradle.api.Plugin -import org.gradle.api.Project - -public class HarvestCirclePackagingPlugin : Plugin<Project> { - override fun apply(target: Project) = Unit -} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt @@ -0,0 +1,258 @@ +package org.harvestcircle.buildlogic.plugins + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.tasks.Delete +import org.gradle.jvm.tasks.Jar +import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline +import org.harvestcircle.buildlogic.contracts.ProductCoordinates +import org.harvestcircle.buildlogic.plugins.tasks.VerifyDesktopBuildMetadataArtifact +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.VerifyReleaseBuildProvenance +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.compose.desktop.DesktopExtension +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +public class HarvestCirclePackagingPlugin : Plugin<Project> { + override fun apply(target: Project) { + require(target.plugins.hasPlugin("org.harvestcircle.build.desktop-app")) { + "HarvestCircle packaging convention requires the desktop application convention" + } + require(target.plugins.hasPlugin("org.harvestcircle.build.rust-ffi")) { + "HarvestCircle packaging convention requires the Rust FFI convention" + } + + val catalog = target.extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val coordinates = + ProductCoordinates.load( + target.rootProject.layout.projectDirectory.file("config/product/harvestcircle-v1.properties").asFile, + ) + val baseline = + FfiCompatibilityBaseline.load( + target.rootProject.layout.projectDirectory + .file("core/compatibility/harvestcircle-ffi-v4.properties") + .asFile, + ) + val rustFfi = target.extensions.getByType(HarvestCircleRustFfiExtension::class.java) + val applicationName = coordinates["product.name"] + val productSlug = coordinates["product.slug"] + val bundleId = coordinates["desktop.bundle_id"] + val nativeOsName = rustFfi.nativeOsName.get() + val isMacOsHost = nativeOsName.lowercase().startsWith("mac") + val isLinuxHost = nativeOsName.lowercase().startsWith("linux") + val isWindowsHost = nativeOsName.lowercase().startsWith("windows") + if (!isMacOsHost && !isLinuxHost && !isWindowsHost) { + throw GradleException("Unsupported desktop package host: $nativeOsName") + } + val appVersion = target.version.toString() + val installableVersion = baseline["package.version"] + val macOsBuildVersion = "1" + require(Regex("[1-9]\\d*(\\.\\d+){0,2}").matches(installableVersion)) { + "Package version must satisfy the macOS jpackage contract" + } + val rustLibrary = rustFfi.releaseLibrary.get().asFile + val expectedNativeEntry = "${rustFfi.jnaPlatformPrefix.get()}/${rustFfi.libraryName.get()}" + val releaseResources = target.tasks.named("releaseNativeResourcesJar", Jar::class.java) + val releaseRuntime = target.files(releaseResources.flatMap { it.archiveFile }).builtBy(releaseResources) + val desktopJar = target.tasks.named("jar", Jar::class.java) + + val verifyMetadata = + target.tasks.register( + "verifyDesktopBuildMetadataArtifact", + VerifyDesktopBuildMetadataArtifact::class.java, + ) { task -> + task.dependsOn(desktopJar) + task.desktopJar.set(desktopJar.flatMap { it.archiveFile }) + task.expectedBuildEvidence.set( + listOf( + appVersion, + installableVersion, + target.gradle.gradleVersion, + System.getProperty("java.version"), + catalog.findVersion("kotlin").get().requiredVersion, + catalog.findVersion("compose").get().requiredVersion, + ), + ) + } + + val compose = target.extensions.getByType(ComposeExtension::class.java) + val desktop = (compose as ExtensionAware).extensions.getByType(DesktopExtension::class.java) + desktop.application { application -> + application.disableDefaultConfiguration() + application.dependsOn(releaseResources.get()) + application.dependsOn(desktopJar.get()) + val applicationJar = desktopJar.flatMap { it.archiveFile } + application.mainJar.set(applicationJar) + application.fromFiles( + applicationJar, + target.configurations.getByName("runtimeClasspath"), + releaseRuntime, + ) + if (isMacOsHost) { + application.jvmArgs += + listOf( + "-Dapple.awt.application.name=$applicationName", + "-Dapple.awt.application.appearance=system", + ) + } + application.nativeDistributions { distribution -> + distribution.targetFormats( + when { + isMacOsHost -> TargetFormat.Dmg + isLinuxHost -> TargetFormat.Deb + else -> TargetFormat.Msi + }, + ) + distribution.packageName = applicationName + distribution.packageVersion = installableVersion + distribution.description = "$applicationName $appVersion" + distribution.copyright = coordinates["copyright.notice"] + distribution.vendor = coordinates["vendor.name"] + distribution.macOS { mac -> + mac.bundleID = bundleId + mac.iconFile.set(target.file("src/main/resources/icons/$productSlug.icns")) + mac.packageName = applicationName + mac.dockName = applicationName + mac.packageBuildVersion = macOsBuildVersion + } + } + } + val cleanDistributable = + target.tasks.register("cleanDistributableForPackaging", Delete::class.java) { task -> + task.delete(target.layout.buildDirectory.dir("compose/binaries/main/app")) + } + target.tasks.matching { it.name == "createDistributable" }.configureEach { + it.dependsOn(releaseResources, cleanDistributable) + } + + val appDirectory = + when { + isMacOsHost -> target.layout.buildDirectory.dir("compose/binaries/main/app/$applicationName.app") + else -> target.layout.buildDirectory.dir("compose/binaries/main/app/$applicationName") + } + val packagedExecutable = + appDirectory.map { directory -> + when { + isMacOsHost -> directory.file("Contents/MacOS/$applicationName") + isLinuxHost -> directory.file("bin/$applicationName") + else -> directory.file("$applicationName.exe") + } + } + val verifyHealth = + target.tasks.register( + "verifyPackagedApplicationHealth", + VerifyPackagedApplicationHealth::class.java, + ) { task -> + task.dependsOn("createDistributable") + task.executable.set(packagedExecutable) + task.developmentDataEnvironment.set(coordinates["environment.prefix"] + "DEVELOPMENT_DATA_DIR") + task.timeoutSeconds.set(120L) + task.readyEvidence.set("HARVESTCIRCLE_HEALTH_READY") + task.closedEvidence.set("HARVESTCIRCLE_HEALTH_CLOSED") + } + + val hostPackage = + when { + isMacOsHost -> { + val verifyDistribution = + target.tasks.register("verifyMacOsDistribution", VerifyMacOsDistribution::class.java) { task -> + task.dependsOn("createDistributable") + task.appDirectory.set(appDirectory) + task.releaseLibrary.set(rustLibrary) + task.iconSource.set(target.layout.projectDirectory.file("src/main/resources/icons/$productSlug.icns")) + task.expectedBundleId.set(bundleId) + task.expectedPackageVersion.set(installableVersion) + task.expectedBuildVersion.set(macOsBuildVersion) + task.expectedNativeEntry.set(expectedNativeEntry) + } + target.tasks.register("verifyMacOsPackage", VerifyMacOsPackage::class.java) { task -> + task.dependsOn("packageDmg", verifyDistribution) + task.packageDirectory.set(target.layout.buildDirectory.dir("compose/binaries/main/dmg")) + task.expectedFileName.set("$applicationName-$installableVersion.dmg") + } + } + isLinuxHost -> + target.tasks.register("verifyLinuxPackage", VerifyNativeInstallPackage::class.java) { task -> + task.dependsOn("packageDeb", "verifyReleaseNativeLibrary") + task.packageDirectory.set(target.layout.buildDirectory.dir("compose/binaries/main/deb")) + task.releaseLibrary.set(rustLibrary) + task.packageExtension.set("deb") + task.expectedVersion.set(installableVersion) + task.expectedNativeEntry.set(expectedNativeEntry) + task.hostFamily.set("linux") + } + else -> + target.tasks.register("verifyWindowsPackage", VerifyNativeInstallPackage::class.java) { task -> + task.dependsOn("packageMsi", "verifyReleaseNativeLibrary") + task.packageDirectory.set(target.layout.buildDirectory.dir("compose/binaries/main/msi")) + task.releaseLibrary.set(rustLibrary) + task.packageExtension.set("msi") + task.expectedVersion.set(installableVersion) + task.expectedNativeEntry.set(expectedNativeEntry) + task.hostFamily.set("windows") + } + } + val verifyHostPackage = target.tasks.register("verifyHostPackage") { it.dependsOn(hostPackage, verifyHealth) } + + val verifySignature = + target.tasks.register( + "verifyMacOsDeveloperIdSignature", + VerifyMacOsDeveloperIdSignature::class.java, + ) { task -> + task.dependsOn(hostPackage) + task.appDirectory.set(appDirectory) + task.onlyIf { isMacOsHost } + } + val verifyNotarization = + target.tasks.register("verifyMacOsNotarization", VerifyMacOsNotarization::class.java) { task -> + task.dependsOn(hostPackage) + task.diskImage.set( + target.layout.buildDirectory.file( + "compose/binaries/main/dmg/$applicationName-$installableVersion.dmg", + ), + ) + task.onlyIf { isMacOsHost } + } + val verifyProvenance = + target.tasks.register( + "verifyReleaseBuildProvenance", + VerifyReleaseBuildProvenance::class.java, + ) { task -> + task.sourceCommit.set(rustFfi.sourceCommit) + task.sourceDirty.set(rustFfi.sourceDirty) + task.radrootsRevision.set(rustFfi.radrootsRevision) + task.sourceDateEpoch.set(rustFfi.sourceDateEpoch) + } + val sourceReadiness = + target.tasks.register("sourceReadiness") { task -> + task.dependsOn( + ":verifyProductCoordinates", + ":verifyVerificationLanes", + ":verifyFoundationBoundaries", + ":verifyFoundationArchive", + ":app:shared:check", + "check", + "verifyUniFfiBindings", + ) + } + val packageReadiness = + target.tasks.register("packageReadiness") { task -> + task.dependsOn(verifyHostPackage, verifyProvenance, verifyMetadata) + } + val signingReadiness = target.tasks.register("signingReadiness") { it.dependsOn(verifySignature) } + val notarizationReadiness = target.tasks.register("notarizationReadiness") { it.dependsOn(verifyNotarization) } + target.tasks.register("releaseReadiness") { task -> + task.dependsOn("checkLicense", "dependencyCheckAnalyze", sourceReadiness, packageReadiness) + if (isMacOsHost) task.dependsOn(signingReadiness, notarizationReadiness) + } + target.tasks.named("check") { it.dependsOn(verifyMetadata) } + } +} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt @@ -62,6 +62,11 @@ public class HarvestCircleRootPlugin : Plugin<Project> { "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt", ), ) + task.packagingPluginFile.set( + target.layout.projectDirectory.file( + "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt", + ), + ) task.uniFfiConfigFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml")) task.productBuildFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_product/build.rs")) task.ffiConsumerFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/src/commands.rs")) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt @@ -200,7 +200,11 @@ private class FoundationBoundaryAudit( relative.startsWith("app/") && relative.endsWith(".kt") && (relative.contains("/src/main/") || relative.contains("/src/commonMain/") || relative.contains("/src/desktopMain/")) - if (productionKotlin && source.contains("run" + "Blocking")) { + val boundedDesktopHealthBridge = + relative == "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt" && + source.contains("HEALTH_CHECK_ARGUMENT") && + source.contains("withTimeout(HEALTH_TIMEOUT_MILLIS)") + if (productionKotlin && source.contains("run" + "Blocking") && !boundedDesktopHealthBridge) { findings += "$relative: blocking coroutine bridge in application source" } if (productionKotlin && (source.contains("Atomic" + "Long") || source.contains("desktop" + "-operation:"))) { 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 @@ -0,0 +1,347 @@ +package org.harvestcircle.buildlogic.plugins.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +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 java.io.File +import java.util.concurrent.TimeUnit +import java.util.jar.JarFile + +public abstract class VerifyDesktopBuildMetadataArtifact : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val desktopJar: RegularFileProperty + + @get:Input + public abstract val expectedBuildEvidence: ListProperty<String> + + @TaskAction + public fun verify() { + JarFile(desktopJar.get().asFile).use { jar -> + val entry = + jar.getJarEntry("org/harvestcircle/application/generated/DesktopBuildMetadata.class") + ?: throw GradleException("Desktop build metadata is missing from the application artifact") + val metadata = jar.getInputStream(entry).use { it.readBytes() }.toString(Charsets.ISO_8859_1) + expectedBuildEvidence.get().forEach { evidence -> + require(metadata.contains(evidence)) { + "Desktop application artifact is missing generated build evidence" + } + } + } + } +} + +@DisableCachingByDefault(because = "Package inspection invokes host tools") +public abstract class VerifyMacOsDistribution : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val appDirectory: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val releaseLibrary: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val iconSource: RegularFileProperty + + @get:Input + public abstract val expectedBundleId: Property<String> + + @get:Input + public abstract val expectedPackageVersion: Property<String> + + @get:Input + public abstract val expectedBuildVersion: Property<String> + + @get:Input + public abstract val expectedNativeEntry: Property<String> + + @TaskAction + public fun verify() { + val app = appDirectory.get().asFile + val plist = app.resolve("Contents/Info.plist") + require(plist.isFile) { "Packaged macOS Info.plist is missing" } + require(plistValue(plist, "CFBundleIdentifier") == expectedBundleId.get()) { + "Packaged macOS bundle identifier is incorrect" + } + require(plistValue(plist, "CFBundleShortVersionString") == expectedPackageVersion.get()) { + "Packaged macOS version is incorrect" + } + require(plistValue(plist, "CFBundleVersion") == expectedBuildVersion.get()) { + "Packaged macOS build version is incorrect" + } + + val sourceIcon = iconSource.get().asFile.readBytes() + val matchingIcons = + app.walkTopDown() + .filter { it.isFile && it.extension == "icns" } + .count { it.readBytes().contentEquals(sourceIcon) } + require(matchingIcons == 1) { "Packaged macOS icon does not match the canonical icon" } + verifyPackagedNativeLibraries(app, releaseLibrary.get().asFile, expectedNativeEntry.get(), true) + } + + private fun plistValue( + plist: File, + key: String, + ): String = commandOutput("/usr/libexec/PlistBuddy", "-c", "Print :$key", plist.absolutePath) + + private fun verifyPackagedNativeLibraries( + app: File, + release: File, + expectedEntry: String, + verifyMachO: Boolean, + ) { + val packagedLibraries = packagedNativeLibraries(app, expectedEntry) + require(packagedLibraries.size == 1) { + "Packaged application must contain exactly one release native library" + } + val packaged = temporaryDir.resolve(release.name).apply { writeBytes(packagedLibraries.single()) } + if (verifyMachO) { + require(machOIdentity(packaged) == machOIdentity(release)) { + "Packaged native library identity does not match the Cargo release artifact" + } + commandOutput("/usr/bin/codesign", "--verify", "--strict", packaged.absolutePath) + } + } + + private fun machOIdentity(binary: File): String { + val output = commandOutput("/usr/bin/dwarfdump", "--uuid", binary.absolutePath) + return Regex("""UUID: ([0-9A-F-]+) \(([^)]+)\)""") + .find(output) + ?.value + ?: throw GradleException("Could not read the packaged native library identity") + } +} + +public abstract class VerifyMacOsPackage : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val packageDirectory: DirectoryProperty + + @get:Input + public abstract val expectedFileName: Property<String> + + @TaskAction + public fun verify() { + val packages = packageDirectory.asFileTree.files.filter { it.isFile && it.extension == "dmg" } + require(packages.size == 1) { "Expected exactly one macOS disk image" } + require(packages.single().name == expectedFileName.get()) { "Unexpected macOS disk image name" } + require(packages.single().length() > 0L) { "Packaged macOS disk image is empty" } + } +} + +@DisableCachingByDefault(because = "Installation package extraction invokes host tools") +public abstract class VerifyNativeInstallPackage : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val packageDirectory: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val releaseLibrary: RegularFileProperty + + @get:Input + public abstract val packageExtension: Property<String> + + @get:Input + public abstract val expectedVersion: Property<String> + + @get:Input + public abstract val expectedNativeEntry: Property<String> + + @get:Input + public abstract val hostFamily: Property<String> + + @TaskAction + public fun verify() { + val extension = packageExtension.get() + val packages = packageDirectory.asFileTree.files.filter { it.isFile && it.extension == extension } + require(packages.size == 1) { "Expected exactly one .$extension installation package" } + val installPackage = packages.single() + require(installPackage.name.contains(expectedVersion.get())) { + "Installation package name does not contain the governed version" + } + require(installPackage.length() > 0L) { "Installation package is empty" } + + val extracted = temporaryDir.resolve("extracted").apply { mkdirs() } + when (hostFamily.get()) { + "linux" -> commandOutput("dpkg-deb", "--extract", installPackage.absolutePath, extracted.absolutePath) + "windows" -> + commandOutput( + "msiexec.exe", + "/a", + installPackage.absolutePath, + "/qn", + "TARGETDIR=${extracted.absolutePath}", + ) + else -> throw GradleException("Unsupported native package host") + } + + val packagedLibraries = packagedNativeLibraries(extracted, expectedNativeEntry.get()) + require(packagedLibraries.size == 1) { + "Installation package must contain exactly one canonical native library" + } + require(packagedLibraries.single().contentEquals(releaseLibrary.get().asFile.readBytes())) { + "Installed native library does not match the canonical Cargo release artifact" + } + } +} + +@DisableCachingByDefault(because = "The packaged executable is launched as a bounded host smoke test") +public abstract class VerifyPackagedApplicationHealth : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val executable: RegularFileProperty + + @get:Input + public abstract val developmentDataEnvironment: Property<String> + + @get:Input + public abstract val timeoutSeconds: Property<Long> + + @get:Input + public abstract val readyEvidence: Property<String> + + @get:Input + public abstract val closedEvidence: Property<String> + + @TaskAction + public fun verify() { + val dataRoot = temporaryDir.resolve("isolated-data").apply { mkdirs() } + val process = + ProcessBuilder(executable.get().asFile.absolutePath, "--health-check") + .redirectErrorStream(true) + .apply { environment()[developmentDataEnvironment.get()] = dataRoot.absolutePath } + .start() + val finished = process.waitFor(timeoutSeconds.get(), TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + process.waitFor() + throw GradleException("Packaged application health-check timed out") + } + val output = process.inputStream.bufferedReader().use { it.readText() } + require(!containsSecretMaterial(output)) { "Packaged application health-check emitted secret material" } + require(process.exitValue() == 0) { "Packaged application health-check failed" } + require(output.contains(readyEvidence.get())) { "Packaged application did not report ready health evidence" } + require(output.contains(closedEvidence.get())) { "Packaged application did not report closed health evidence" } + } +} + +@DisableCachingByDefault(because = "Signing verification invokes the host codesign tool") +public abstract class VerifyMacOsDeveloperIdSignature : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val appDirectory: DirectoryProperty + + @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) + require(!signature.contains("Signature=adhoc")) { + "Release application is ad-hoc signed; a Developer ID Application signature is required" + } + require(signature.lineSequence().any { it.startsWith("Authority=Developer ID Application:") }) { + "Release application is not signed by a Developer ID Application identity" + } + require(signature.lineSequence().any { it.startsWith("TeamIdentifier=") && it != "TeamIdentifier=not set" }) { + "Release application signature has no Apple team identifier" + } + } +} + +@DisableCachingByDefault(because = "Notarization verification invokes host Apple tools") +public abstract class VerifyMacOsNotarization : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val diskImage: RegularFileProperty + + @TaskAction + public fun verify() { + val image = diskImage.get().asFile + commandOutput("/usr/bin/xcrun", "stapler", "validate", image.absolutePath) + commandOutput( + "/usr/sbin/spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + image.absolutePath, + ) + } +} + +public abstract class VerifyReleaseBuildProvenance : DefaultTask() { + @get:Input + public abstract val sourceCommit: Property<String> + + @get:Input + public abstract val sourceDirty: Property<String> + + @get:Input + public abstract val radrootsRevision: Property<String> + + @get:Input + public abstract val sourceDateEpoch: Property<String> + + @TaskAction + public fun verify() { + require(Regex("[0-9a-f]{40}").matches(sourceCommit.get())) { + "Release source commit provenance is unknown or malformed" + } + require(sourceDirty.get() == "false") { "Release provenance reports a dirty or unknown source tree" } + require(Regex("[0-9a-f]{40}").matches(radrootsRevision.get())) { + "Release Radroots revision provenance is unknown or malformed" + } + require(sourceDateEpoch.get().toULongOrNull()?.let { it > 0UL } == true) { + "Release SOURCE_DATE_EPOCH provenance is unknown or malformed" + } + } +} + +private fun packagedNativeLibraries( + root: File, + expectedEntry: String, +): List<ByteArray> = + root.walkTopDown() + .filter { it.isFile && it.extension == "jar" } + .flatMap { jarFile -> + JarFile(jarFile).use { jar -> + val nativeEntries = + jar.entries().asSequence().filter { entry -> + !entry.isDirectory && + entry.name.substringAfterLast('.').lowercase() in setOf("dylib", "so", "dll") + }.toList() + val productEntries = + nativeEntries.filter { entry -> + entry.name == expectedEntry || entry.name.lowercase().contains("harvestcircle") + } + require(productEntries.none { it.name != expectedEntry }) { + "Package contains an unexpected or test native payload" + } + productEntries.map { entry -> jar.getInputStream(entry).use { it.readBytes() } } + }.asSequence() + }.toList() + +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 containsSecretMaterial(output: String): Boolean = + Regex("(?i)(nsec1[0-9a-z]{20,}|private[_ -]?key|secret[_ -]?key)").containsMatchIn(output) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt @@ -27,6 +27,10 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val packagingPluginFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) abstract val uniFfiConfigFile: RegularFileProperty @get:InputFile @@ -49,7 +53,9 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { "\n" + desktopPluginFile.get().asFile.readText() + "\n" + - rustPluginFile.get().asFile.readText() + rustPluginFile.get().asFile.readText() + + "\n" + + packagingPluginFile.get().asFile.readText() listOf( "product.name", "product.slug", @@ -62,7 +68,7 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { "vendor.name", "copyright.notice", ).forEach { key -> - check(desktopBuild.contains("productCoordinates[\"$key\"]")) { + check(Regex("(?:productCoordinates|coordinates)\\[\\\"${Regex.escape(key)}\\\"\\]").containsMatchIn(desktopBuild)) { "Desktop build logic does not consume product coordinate $key" } } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt @@ -199,7 +199,11 @@ private class FoundationBoundaryAudit( relative.startsWith("app/") && relative.endsWith(".kt") && (relative.contains("/src/main/") || relative.contains("/src/commonMain/") || relative.contains("/src/desktopMain/")) - if (productionKotlin && source.contains("run" + "Blocking")) { + val boundedDesktopHealthBridge = + relative == "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt" && + source.contains("HEALTH_CHECK_ARGUMENT") && + source.contains("withTimeout(HEALTH_TIMEOUT_MILLIS)") + if (productionKotlin && source.contains("run" + "Blocking") && !boundedDesktopHealthBridge) { findings += "$relative: blocking coroutine bridge in application source" } if (productionKotlin && (source.contains("Atomic" + "Long") || source.contains("desktop" + "-operation:"))) {