app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 6c61b08a682f5230ceb006a5ae6750182be60cca
parent aa1c9b5a62ea782f3ac8470f433acfa30920aa94
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 17:46:01 +0000

release: complete build information and readiness

- generate host-owned product, package, Gradle, Java, Kotlin, and Compose facts
- combine desktop metadata with native provenance without changing the FFI surface
- report typed release-readiness failures across every required contract field
- verify canonical provenance, generated freshness, and packaged metadata evidence

Diffstat:
Mapp/desktop/build.gradle.kts | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt | 2+-
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt | 28+++++++++++++++++++++++++---
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/BuildInfo.kt | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mbuild.gradle.kts | 6++++++
AbuildSrc/src/main/kotlin/org/harvestcircle/gradle/DesktopBuildMetadata.kt | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 466 insertions(+), 32 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -19,8 +19,10 @@ import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar import org.harvestcircle.gradle.FfiCompatibilityBaseline import org.harvestcircle.gradle.GenerateCompatibilityExpectations +import org.harvestcircle.gradle.GenerateDesktopBuildMetadata import org.harvestcircle.gradle.ProductCoordinates import org.harvestcircle.gradle.VerifyGeneratedCompatibilityExpectations +import org.harvestcircle.gradle.VerifyGeneratedDesktopBuildMetadata import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -290,12 +292,34 @@ val generatedCompatibilityFile = generatedCompatibilityKotlin.map { it.file("org/harvestcircle/application/generated/NativeCompatibilityExpectations.kt") } +val generatedDesktopBuildMetadataFile = + generatedCompatibilityKotlin.map { + it.file("org/harvestcircle/application/generated/DesktopBuildMetadata.kt") + } + +fun org.harvestcircle.gradle.DesktopBuildMetadataTask.configureDesktopBuildMetadata() { + productVersion.set(appVersion) + distributionPackageVersion.set(installableVersion) + gradleToolchain.set(gradle.gradleVersion) + javaToolchain.set(System.getProperty("java.version")) + kotlinToolchain.set(libs.versions.kotlin.get()) + composeMultiplatformVersion.set(libs.versions.compose.get()) +} +val generateDesktopBuildMetadata by tasks.registering(GenerateDesktopBuildMetadata::class) { + configureDesktopBuildMetadata() + outputFile.set(generatedDesktopBuildMetadataFile) +} val generateCompatibilityExpectations by tasks.registering(GenerateCompatibilityExpectations::class) { baselineFile.set(ffiCompatibilityBaselineFile) outputFile.set(generatedCompatibilityFile) } +val verifyGeneratedDesktopBuildMetadata by tasks.registering(VerifyGeneratedDesktopBuildMetadata::class) { + dependsOn(generateDesktopBuildMetadata) + configureDesktopBuildMetadata() + generatedFile.set(generatedDesktopBuildMetadataFile) +} val verifyGeneratedSources by tasks.registering(VerifyGeneratedCompatibilityExpectations::class) { - dependsOn(generateCompatibilityExpectations) + dependsOn(generateCompatibilityExpectations, verifyGeneratedDesktopBuildMetadata) baselineFile.set(ffiCompatibilityBaselineFile) generatedFile.set(generatedCompatibilityFile) } @@ -424,6 +448,44 @@ val releaseNativeRuntimeJar = .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) @@ -746,6 +808,7 @@ tasks.named("check") { dependsOn(rootProject.tasks.named("verifyProductCoordinates")) dependsOn(rootProject.tasks.named("verifyProductCoordinateConsumers")) dependsOn(verifyGeneratedSources) + dependsOn(verifyDesktopBuildMetadataArtifact) } kotlin { @@ -757,7 +820,7 @@ kotlin { } tasks.named<KotlinCompile>("compileKotlin") { - dependsOn(generateUniFfiKotlin, generateCompatibilityExpectations) + dependsOn(generateUniFfiKotlin, generateCompatibilityExpectations, generateDesktopBuildMetadata) source(generatedUniFfiKotlin, generatedCompatibilityKotlin) } tasks.named("runKtlintCheckOverMainSourceSet") { @@ -935,7 +998,7 @@ val sourceReadiness by tasks.registering { ) } val packageReadiness by tasks.registering { - dependsOn(verifyHostPackage, verifyReleaseBuildProvenance) + dependsOn(verifyHostPackage, verifyReleaseBuildProvenance, verifyDesktopBuildMetadataArtifact) } val signingReadiness by tasks.registering { dependsOn(verifyMacOsDeveloperIdSignature) diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -31,7 +31,7 @@ class NativeHarvestCircleRuntime internal constructor( private val native: NativeCorePort, private val handleIds: NativeHandleIdSource = GeneratedNativeHandleIdSource, ) : HarvestCircleRuntime { - override val buildInfo: BuildInfo = nativeBuildInfo().toBuildInfo() + override val buildInfo: BuildInfo = nativeBuildInfo().toBuildInfo(compatibilityDescriptor()) private val recoveryMutex = Mutex() private val recoveryHandles = mutableMapOf<RecoveryRequestId, NativeGeneratedRecoveryHandle>() diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt @@ -4,6 +4,7 @@ import org.harvestcircle.ffi.ActiveIdentityDto import org.harvestcircle.ffi.AppLifecycleDto import org.harvestcircle.ffi.AppSnapshotDto import org.harvestcircle.ffi.BuildInfoDto +import org.harvestcircle.ffi.CompatibilityDescriptor import org.harvestcircle.ffi.HarvestCircleException import org.harvestcircle.ffi.IdentityCommandReceiptDto import org.harvestcircle.ffi.IdentityDto @@ -20,9 +21,13 @@ import org.harvestcircle.ffi.SnapshotChangeDto import org.harvestcircle.ffi.WireErrorCategory import org.harvestcircle.ffi.WireErrorCode import org.harvestcircle.ffi.WireRecoveryAction +import org.harvestcircle.application.generated.DesktopBuildMetadata as DesktopBuild +import org.harvestcircle.application.generated.NativeCompatibilityExpectations as Expected -internal fun BuildInfoDto.toBuildInfo(): BuildInfo = +internal fun BuildInfoDto.toBuildInfo(descriptor: CompatibilityDescriptor): BuildInfo = BuildInfo( + productVersion = DesktopBuild.productVersion, + distributionPackageVersion = DesktopBuild.distributionPackageVersion, sourceCommit = sourceCommit, sourceDirty = when (sourceDirty) { @@ -32,15 +37,32 @@ internal fun BuildInfoDto.toBuildInfo(): BuildInfo = }, radrootsRevision = radrootsRevision, rustToolchain = rustToolchain, - javaToolchain = javaToolchain, - kotlinToolchain = kotlinToolchain, + gradleToolchain = DesktopBuild.gradleToolchain, + javaToolchain = DesktopBuild.javaToolchain, + kotlinToolchain = DesktopBuild.kotlinToolchain, + composeMultiplatformVersion = DesktopBuild.composeMultiplatformVersion, provenanceDigest = provenanceDigest, sourceDateEpoch = sourceDateEpoch, ffiContractId = ffiContractId, + ffiContractMajor = descriptor.contractMajor, + ffiContractMinor = descriptor.contractMinor, ffiContractHash = ffiContractHash, snapshotSchemaVersion = snapshotSchemaVersion, minimumStorageSchemaVersion = minimumStorageSchemaVersion, currentStorageSchemaVersion = currentStorageSchemaVersion, + eventRegistryState = EventRegistryState.NotApplicable, + releaseCriteria = + BuildReleaseCriteria( + productVersion = Expected.productVersion, + distributionPackageVersion = Expected.distributionPackageVersion, + ffiContractId = Expected.ffiContractId, + ffiContractMajor = Expected.ffiContractMajor, + ffiContractMinor = Expected.minimumFfiContractMinor, + ffiContractHash = Expected.ffiContractHash, + snapshotSchemaVersion = Expected.snapshotSchema, + minimumStorageSchemaVersion = Expected.minimumStorageSchema, + maximumStorageSchemaVersion = Expected.maximumStorageSchema, + ), ) internal fun RequestContext.toNative(): RequestContextDto = diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.test.runTest import org.harvestcircle.ffi.ActiveIdentityDto import org.harvestcircle.ffi.AppLifecycleDto import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.BuildInfoDto +import org.harvestcircle.ffi.CompatibilityDescriptor import org.harvestcircle.ffi.HarvestCircleException import org.harvestcircle.ffi.IdentityCommandReceiptDto import org.harvestcircle.ffi.IdentityDto @@ -37,9 +39,56 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import org.harvestcircle.application.generated.NativeCompatibilityExpectations as Expected class NativeRuntimeMappingsTest { @Test + fun desktopCombinesNativeAndHostOwnedBuildMetadataForReadiness() { + val descriptor = + CompatibilityDescriptor( + contractId = Expected.ffiContractId, + productVersion = Expected.productVersion, + cargoPackageVersion = Expected.cargoPackageVersion, + distributionPackageVersion = Expected.distributionPackageVersion, + contractMajor = Expected.ffiContractMajor, + contractMinor = Expected.minimumFfiContractMinor, + contractHash = Expected.ffiContractHash, + productCoordinateDigest = Expected.productCoordinateDigest, + snapshotSchemaVersion = Expected.snapshotSchema, + minimumSchemaVersion = Expected.minimumStorageSchema, + currentSchemaVersion = Expected.maximumStorageSchema, + sourceProvenanceDigest = Expected.sourceProvenanceDigest, + sourceFoundationBaseline = Expected.sourceFoundationBaseline, + ) + val native = + BuildInfoDto( + sourceCommit = "a".repeat(40), + sourceDirty = "false", + radrootsRevision = "b".repeat(40), + rustToolchain = "1.97.1", + javaToolchain = "native-value-must-not-win", + kotlinToolchain = "native-value-must-not-win", + provenanceDigest = "c".repeat(64), + sourceDateEpoch = 1_700_000_000UL, + ffiContractId = descriptor.contractId, + ffiContractHash = descriptor.contractHash, + snapshotSchemaVersion = Expected.snapshotSchema, + minimumStorageSchemaVersion = Expected.minimumStorageSchema, + currentStorageSchemaVersion = Expected.maximumStorageSchema, + ) + + val mapped = native.toBuildInfo(descriptor) + + assertEquals(Expected.productVersion, mapped.productVersion) + assertEquals(Expected.distributionPackageVersion, mapped.distributionPackageVersion) + assertEquals("9.5.0", mapped.gradleToolchain) + assertEquals("2.4.10", mapped.kotlinToolchain) + assertEquals("1.11.1", mapped.composeMultiplatformVersion) + assertEquals(EventRegistryState.NotApplicable, mapped.eventRegistryState) + assertTrue(mapped.releaseReady, mapped.releaseReadinessProblems.toString()) + } + + @Test fun everyGeneratedEnumMapsExhaustively() { assertEquals( 11, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/BuildInfo.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/BuildInfo.kt @@ -1,66 +1,180 @@ package org.harvestcircle.application data class BuildInfo( + val productVersion: String, + val distributionPackageVersion: String, val sourceCommit: String, val sourceDirty: BuildDirtyState, val radrootsRevision: String, val rustToolchain: String, + val gradleToolchain: String, val javaToolchain: String, val kotlinToolchain: String, + val composeMultiplatformVersion: String, val provenanceDigest: String, val sourceDateEpoch: ULong, val ffiContractId: String, + val ffiContractMajor: UShort, + val ffiContractMinor: UShort, val ffiContractHash: String, val snapshotSchemaVersion: UInt, val minimumStorageSchemaVersion: UInt, val currentStorageSchemaVersion: UInt, + val eventRegistryState: EventRegistryState, + val releaseCriteria: BuildReleaseCriteria, ) { companion object { fun unknown(): BuildInfo = BuildInfo( + productVersion = UNKNOWN_PROVENANCE, + distributionPackageVersion = UNKNOWN_PROVENANCE, sourceCommit = UNKNOWN_PROVENANCE, sourceDirty = BuildDirtyState.Unknown, radrootsRevision = UNKNOWN_PROVENANCE, rustToolchain = UNKNOWN_PROVENANCE, + gradleToolchain = UNKNOWN_PROVENANCE, javaToolchain = UNKNOWN_PROVENANCE, kotlinToolchain = UNKNOWN_PROVENANCE, + composeMultiplatformVersion = UNKNOWN_PROVENANCE, provenanceDigest = UNKNOWN_PROVENANCE, sourceDateEpoch = 0UL, ffiContractId = UNKNOWN_PROVENANCE, + ffiContractMajor = 0.toUShort(), + ffiContractMinor = 0.toUShort(), ffiContractHash = UNKNOWN_PROVENANCE, snapshotSchemaVersion = 0U, minimumStorageSchemaVersion = 0U, currentStorageSchemaVersion = 0U, + eventRegistryState = EventRegistryState.NotApplicable, + releaseCriteria = BuildReleaseCriteria.unknown(), ) } val releaseReady: Boolean + get() = releaseReadinessProblems.isEmpty() + + val releaseReadinessProblems: Set<ReleaseReadinessProblem> get() = - sourceCommit != UNKNOWN_PROVENANCE && - radrootsRevision != UNKNOWN_PROVENANCE && - sourceDirty == BuildDirtyState.Clean && - rustToolchain != UNKNOWN_PROVENANCE && - javaToolchain != UNKNOWN_PROVENANCE && - kotlinToolchain != UNKNOWN_PROVENANCE && - provenanceDigest != UNKNOWN_PROVENANCE + buildSet { + if (productVersion != releaseCriteria.productVersion) add(ReleaseReadinessProblem.ProductVersion) + if (distributionPackageVersion != releaseCriteria.distributionPackageVersion) { + add(ReleaseReadinessProblem.DistributionPackageVersion) + } + if (!sourceCommit.isCanonicalHex(40)) add(ReleaseReadinessProblem.SourceCommit) + if (sourceDirty != BuildDirtyState.Clean) add(ReleaseReadinessProblem.SourceDirty) + if (!radrootsRevision.isCanonicalHex(40)) add(ReleaseReadinessProblem.RadrootsRevision) + if (!rustToolchain.isKnownVersion()) add(ReleaseReadinessProblem.RustToolchain) + if (!gradleToolchain.isKnownVersion()) add(ReleaseReadinessProblem.GradleToolchain) + if (!javaToolchain.isKnownVersion()) add(ReleaseReadinessProblem.JavaToolchain) + if (!kotlinToolchain.isKnownVersion()) add(ReleaseReadinessProblem.KotlinToolchain) + if (!composeMultiplatformVersion.isKnownVersion()) add(ReleaseReadinessProblem.ComposeMultiplatform) + if (!provenanceDigest.isCanonicalHex(64)) add(ReleaseReadinessProblem.ProvenanceDigest) + if (sourceDateEpoch == 0UL) add(ReleaseReadinessProblem.SourceDateEpoch) + if (ffiContractId != releaseCriteria.ffiContractId) add(ReleaseReadinessProblem.FfiContractId) + if (ffiContractMajor != releaseCriteria.ffiContractMajor) add(ReleaseReadinessProblem.FfiContractMajor) + if (ffiContractMinor != releaseCriteria.ffiContractMinor) add(ReleaseReadinessProblem.FfiContractMinor) + if (!ffiContractHash.isCanonicalHex(64) || ffiContractHash != releaseCriteria.ffiContractHash) { + add(ReleaseReadinessProblem.FfiContractHash) + } + if (snapshotSchemaVersion == 0U || snapshotSchemaVersion != releaseCriteria.snapshotSchemaVersion) { + add(ReleaseReadinessProblem.SnapshotSchema) + } + if ( + minimumStorageSchemaVersion == 0U || + minimumStorageSchemaVersion > currentStorageSchemaVersion || + currentStorageSchemaVersion < releaseCriteria.minimumStorageSchemaVersion || + minimumStorageSchemaVersion > releaseCriteria.maximumStorageSchemaVersion + ) { + add(ReleaseReadinessProblem.StorageSchema) + } + if (eventRegistryState != EventRegistryState.NotApplicable) { + add(ReleaseReadinessProblem.EventRegistry) + } + } fun safeDiagnostics(): Map<String, String> = linkedMapOf( + "productVersion" to productVersion, + "distributionPackageVersion" to distributionPackageVersion, "sourceCommit" to sourceCommit, "sourceDirty" to sourceDirty.name.lowercase(), "radrootsRevision" to radrootsRevision, "rustToolchain" to rustToolchain, + "gradleToolchain" to gradleToolchain, "javaToolchain" to javaToolchain, "kotlinToolchain" to kotlinToolchain, + "composeMultiplatformVersion" to composeMultiplatformVersion, "provenanceDigest" to provenanceDigest, "sourceDateEpoch" to sourceDateEpoch.toString(), "ffiContractId" to ffiContractId, + "ffiContractVersion" to "$ffiContractMajor.$ffiContractMinor", "ffiContractHash" to ffiContractHash, "snapshotSchemaVersion" to snapshotSchemaVersion.toString(), "storageSchemaRange" to "$minimumStorageSchemaVersion..$currentStorageSchemaVersion", + "eventRegistryState" to eventRegistryState.diagnosticName, + "releaseReady" to releaseReady.toString(), + "releaseReadinessProblems" to releaseReadinessProblems.joinToString(",") { it.diagnosticName }, ) } +data class BuildReleaseCriteria( + val productVersion: String, + val distributionPackageVersion: String, + val ffiContractId: String, + val ffiContractMajor: UShort, + val ffiContractMinor: UShort, + val ffiContractHash: String, + val snapshotSchemaVersion: UInt, + val minimumStorageSchemaVersion: UInt, + val maximumStorageSchemaVersion: UInt, +) { + companion object { + fun unknown(): BuildReleaseCriteria = + BuildReleaseCriteria( + productVersion = UNKNOWN_PROVENANCE, + distributionPackageVersion = UNKNOWN_PROVENANCE, + ffiContractId = UNKNOWN_PROVENANCE, + ffiContractMajor = 0.toUShort(), + ffiContractMinor = 0.toUShort(), + ffiContractHash = UNKNOWN_PROVENANCE, + snapshotSchemaVersion = 0U, + minimumStorageSchemaVersion = 0U, + maximumStorageSchemaVersion = 0U, + ) + } +} + +enum class EventRegistryState( + val diagnosticName: String, +) { + NotApplicable("not_applicable"), + Unknown("unknown"), +} + +enum class ReleaseReadinessProblem( + val diagnosticName: String, +) { + ProductVersion("product_version"), + DistributionPackageVersion("distribution_package_version"), + SourceCommit("source_commit"), + SourceDirty("source_dirty"), + RadrootsRevision("radroots_revision"), + RustToolchain("rust_toolchain"), + GradleToolchain("gradle_toolchain"), + JavaToolchain("java_toolchain"), + KotlinToolchain("kotlin_toolchain"), + ComposeMultiplatform("compose_multiplatform"), + ProvenanceDigest("provenance_digest"), + SourceDateEpoch("source_date_epoch"), + FfiContractId("ffi_contract_id"), + FfiContractMajor("ffi_contract_major"), + FfiContractMinor("ffi_contract_minor"), + FfiContractHash("ffi_contract_hash"), + SnapshotSchema("snapshot_schema"), + StorageSchema("storage_schema"), + EventRegistry("event_registry"), +} + enum class BuildDirtyState { Clean, Dirty, @@ -68,3 +182,7 @@ enum class BuildDirtyState { } const val UNKNOWN_PROVENANCE = "unknown" + +private fun String.isCanonicalHex(length: Int): Boolean = this.length == length && all { it in '0'..'9' || it in 'a'..'f' } + +private fun String.isKnownVersion(): Boolean = this != UNKNOWN_PROVENANCE && isNotBlank() && length <= 128 && all { it.code in 0x21..0x7e } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt @@ -9,31 +9,52 @@ import kotlin.test.assertTrue class RuntimeContractsTest { @Test fun buildInfoRejectsUnknownAndDirtyReleaseInputsAndKeepsDiagnosticsSafe() { - val clean = - BuildInfo( - sourceCommit = "a".repeat(40), - sourceDirty = BuildDirtyState.Clean, - radrootsRevision = "b".repeat(40), - rustToolchain = "1.97.1", - javaToolchain = "21.0.11", - kotlinToolchain = "2.4.10", - provenanceDigest = "d".repeat(64), - sourceDateEpoch = 1_700_000_000UL, - ffiContractId = "harvestcircle-desktop-ffi-v4", - ffiContractHash = "c".repeat(64), - snapshotSchemaVersion = 1U, - minimumStorageSchemaVersion = 5U, - currentStorageSchemaVersion = 10U, - ) + val clean = releaseReadyBuildInfo() assertTrue(clean.releaseReady) + assertEquals(emptySet(), clean.releaseReadinessProblems) assertEquals("1700000000", clean.safeDiagnostics()["sourceDateEpoch"]) + assertEquals("not_applicable", clean.safeDiagnostics()["eventRegistryState"]) assertFalse(clean.safeDiagnostics().values.any { it.contains("nsec1") }) - assertFalse(clean.copy(sourceDirty = BuildDirtyState.Dirty).releaseReady) - assertFalse(clean.copy(sourceCommit = UNKNOWN_PROVENANCE).releaseReady) assertFalse(BuildInfo.unknown().releaseReady) } @Test + fun buildInfoReportsEveryFieldSpecificReadinessFailure() { + val clean = releaseReadyBuildInfo() + val failures = + mapOf( + ReleaseReadinessProblem.ProductVersion to clean.copy(productVersion = "wrong"), + ReleaseReadinessProblem.DistributionPackageVersion to + clean.copy(distributionPackageVersion = "wrong"), + ReleaseReadinessProblem.SourceCommit to clean.copy(sourceCommit = "A".repeat(40)), + ReleaseReadinessProblem.SourceDirty to clean.copy(sourceDirty = BuildDirtyState.Dirty), + ReleaseReadinessProblem.RadrootsRevision to clean.copy(radrootsRevision = "short"), + ReleaseReadinessProblem.RustToolchain to clean.copy(rustToolchain = UNKNOWN_PROVENANCE), + ReleaseReadinessProblem.GradleToolchain to clean.copy(gradleToolchain = UNKNOWN_PROVENANCE), + ReleaseReadinessProblem.JavaToolchain to clean.copy(javaToolchain = UNKNOWN_PROVENANCE), + ReleaseReadinessProblem.KotlinToolchain to clean.copy(kotlinToolchain = UNKNOWN_PROVENANCE), + ReleaseReadinessProblem.ComposeMultiplatform to + clean.copy(composeMultiplatformVersion = UNKNOWN_PROVENANCE), + ReleaseReadinessProblem.ProvenanceDigest to clean.copy(provenanceDigest = "bad"), + ReleaseReadinessProblem.SourceDateEpoch to clean.copy(sourceDateEpoch = 0UL), + ReleaseReadinessProblem.FfiContractId to clean.copy(ffiContractId = "wrong"), + ReleaseReadinessProblem.FfiContractMajor to clean.copy(ffiContractMajor = 5.toUShort()), + ReleaseReadinessProblem.FfiContractMinor to clean.copy(ffiContractMinor = 1.toUShort()), + ReleaseReadinessProblem.FfiContractHash to clean.copy(ffiContractHash = "bad"), + ReleaseReadinessProblem.SnapshotSchema to clean.copy(snapshotSchemaVersion = 0U), + ReleaseReadinessProblem.StorageSchema to + clean.copy(minimumStorageSchemaVersion = 11U, currentStorageSchemaVersion = 10U), + ReleaseReadinessProblem.EventRegistry to + clean.copy(eventRegistryState = EventRegistryState.Unknown), + ) + + failures.forEach { (expected, buildInfo) -> + assertEquals(setOf(expected), buildInfo.releaseReadinessProblems, expected.diagnosticName) + assertFalse(buildInfo.releaseReady) + } + } + + @Test fun identifiersAndCommandInputsValidateAndRedact() { val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) assertEquals("01".repeat(32), identityId.value) @@ -153,6 +174,44 @@ class RuntimeContractsTest { } } +private fun releaseReadyBuildInfo(): BuildInfo { + val criteria = + BuildReleaseCriteria( + productVersion = "0.1.0-alpha", + distributionPackageVersion = "1.0.0", + ffiContractId = "harvestcircle-desktop-ffi-v4", + ffiContractMajor = 4.toUShort(), + ffiContractMinor = 0.toUShort(), + ffiContractHash = "c".repeat(64), + snapshotSchemaVersion = 1U, + minimumStorageSchemaVersion = 5U, + maximumStorageSchemaVersion = 10U, + ) + return BuildInfo( + productVersion = criteria.productVersion, + distributionPackageVersion = criteria.distributionPackageVersion, + sourceCommit = "a".repeat(40), + sourceDirty = BuildDirtyState.Clean, + radrootsRevision = "b".repeat(40), + rustToolchain = "1.97.1", + gradleToolchain = "9.5.0", + javaToolchain = "21.0.11", + kotlinToolchain = "2.4.10", + composeMultiplatformVersion = "1.11.1", + provenanceDigest = "d".repeat(64), + sourceDateEpoch = 1_700_000_000UL, + ffiContractId = criteria.ffiContractId, + ffiContractMajor = criteria.ffiContractMajor, + ffiContractMinor = criteria.ffiContractMinor, + ffiContractHash = criteria.ffiContractHash, + snapshotSchemaVersion = criteria.snapshotSchemaVersion, + minimumStorageSchemaVersion = criteria.minimumStorageSchemaVersion, + currentStorageSchemaVersion = criteria.maximumStorageSchemaVersion, + eventRegistryState = EventRegistryState.NotApplicable, + releaseCriteria = criteria, + ) +} + private fun snapshot(revision: ULong): ApplicationSnapshot { val identity = IdentitySummary( diff --git a/build.gradle.kts b/build.gradle.kts @@ -41,6 +41,12 @@ val verifyCompatibilityBaseline by tasks.registering { dependsOn(verifyProductCoordinates) } +val verifySourceProvenance by tasks.registering { + group = "verification" + description = "Validates canonical source provenance and its governed digest." + dependsOn(verifyProductCoordinates) +} + val verifyProductCoordinateConsumers by tasks.registering(VerifyProductCoordinateConsumers::class) { group = "verification" description = "Validates that build and runtime identities consume the product manifest." diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/DesktopBuildMetadata.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/DesktopBuildMetadata.kt @@ -0,0 +1,117 @@ +package org.harvestcircle.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +data class DesktopBuildMetadataValues( + val productVersion: String, + val distributionPackageVersion: String, + val gradleToolchain: String, + val javaToolchain: String, + val kotlinToolchain: String, + val composeMultiplatformVersion: String, +) + +object DesktopBuildMetadataSource { + fun render(values: DesktopBuildMetadataValues): String = + """ + |// @generated by the HarvestCircle Gradle build. Do not edit. + |package org.harvestcircle.application.generated + | + |internal object DesktopBuildMetadata { + | const val productVersion = ${values.productVersion.quoted()} + | const val distributionPackageVersion = ${values.distributionPackageVersion.quoted()} + | const val gradleToolchain = ${values.gradleToolchain.quoted()} + | const val javaToolchain = ${values.javaToolchain.quoted()} + | const val kotlinToolchain = ${values.kotlinToolchain.quoted()} + | const val composeMultiplatformVersion = ${values.composeMultiplatformVersion.quoted()} + |} + """.trimMargin() + "\n" + + fun requireFresh( + actual: String?, + expected: String, + ) { + require(actual != null) { "Generated desktop build metadata is missing" } + require(actual == expected) { "Generated desktop build metadata is stale" } + } + + private fun String.quoted(): String = + buildString { + append('"') + this@quoted.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + append('"') + } +} + +abstract class DesktopBuildMetadataTask : DefaultTask() { + @get:Input + abstract val productVersion: Property<String> + + @get:Input + abstract val distributionPackageVersion: Property<String> + + @get:Input + abstract val gradleToolchain: Property<String> + + @get:Input + abstract val javaToolchain: Property<String> + + @get:Input + abstract val kotlinToolchain: Property<String> + + @get:Input + abstract val composeMultiplatformVersion: Property<String> + + protected fun values(): DesktopBuildMetadataValues = + DesktopBuildMetadataValues( + productVersion = productVersion.get(), + distributionPackageVersion = distributionPackageVersion.get(), + gradleToolchain = gradleToolchain.get(), + javaToolchain = javaToolchain.get(), + kotlinToolchain = kotlinToolchain.get(), + composeMultiplatformVersion = composeMultiplatformVersion.get(), + ) +} + +abstract class GenerateDesktopBuildMetadata : DesktopBuildMetadataTask() { + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + output.writeText(DesktopBuildMetadataSource.render(values())) + } +} + +abstract class VerifyGeneratedDesktopBuildMetadata : DesktopBuildMetadataTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val generatedFile: RegularFileProperty + + @TaskAction + fun verify() { + val expected = DesktopBuildMetadataSource.render(values()) + check(runCatching { DesktopBuildMetadataSource.requireFresh(null, expected) }.isFailure) + check(runCatching { DesktopBuildMetadataSource.requireFresh("$expected// stale\n", expected) }.isFailure) + DesktopBuildMetadataSource.requireFresh(generatedFile.get().asFile.readText(), expected) + } +}