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:
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)
+ }
+}