Files
amethyst/geode/build.gradle.kts
T
Claude 0a30231196 feat(geode): add a release & distribution pipeline
geode was runnable only via ./gradlew :geode:run and was absent from CI.
Give it the same release process as the amy CLI (it's the same kind of
application-plugin JVM module), plus the pieces a long-running server
daemon needs that a one-shot CLI does not.

- Main.kt: add terminal --version/-V and --help/-h flags so a packaged
  binary has a fast, exit-0 command (Homebrew test block, package smoke
  checks, Docker healthcheck).
- build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image
  with a bundled JRE, plus config.example.toml + geode.service under
  share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to
  exclude — geode depends only on :quartz.
- Dockerfile + .dockerignore: multi-stage image (gradle installDist ->
  temurin JRE), the primary channel for relay operators.
- packaging/: systemd unit, macOS hardened-runtime entitlements, and a
  reference Homebrew formula.
- scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the
  canonical geode-<version>-<family>-<arch>.<ext> scheme.
- create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm
  bundle, with a serve+NIP-11 smoke test of the jlink image) and a
  docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest).
- bump-homebrew-geode-formula.yml: auto-sync the reference formula on
  stable releases.
- build.yml: run :geode:test in CI (it ran in no workflow before).
- README.md + plans/2026-07-24-geode-release.md: operator docs + design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
2026-07-24 17:56:50 +00:00

345 lines
13 KiB
Kotlin

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.serialization)
application
`java-test-fixtures`
}
application {
mainClass.set("com.vitorpamplona.geode.MainKt")
applicationName = "geode"
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
// Generate a BuildConfig.kt carrying the app version from the catalog so
// RelayInfo.VERSION (reported over NIP-11) tracks releases automatically
// instead of being a hand-bumped literal.
val generateVersionFile by tasks.registering {
val versionValue = libs.versions.app.get()
val outDir = layout.buildDirectory.dir("generated/version/kotlin")
inputs.property("version", versionValue)
outputs.dir(outDir)
doLast {
val file = outDir.get().file("com/vitorpamplona/geode/BuildConfig.kt").asFile
file.parentFile.mkdirs()
file.writeText(
buildString {
appendLine("// Generated by the :geode build — do not edit.")
appendLine("package com.vitorpamplona.geode")
appendLine()
appendLine("internal object BuildConfig {")
appendLine(" const val VERSION = \"$versionValue\"")
appendLine("}")
},
)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
kotlin.srcDir(generateVersionFile)
}
test {
kotlin.srcDir("src/test/kotlin")
}
// The `java-test-fixtures` plugin auto-creates a `testFixtures`
// source set; we just point it at our Kotlin layout so the
// `geode.fixtures` (synthetic events) and `geode.testing`
// (RelayClientTest, collectUntilEose) packages don't ship in
// production jars but are still usable by every consumer's test
// source via `testImplementation(testFixtures(project(":geode")))`.
named("testFixtures") {
kotlin.srcDir("src/testFixtures/kotlin")
}
}
tasks.withType<Test>().configureEach {
// Forward `-DrunLoadBenchmark=true` to the test JVM so the
// perf.LoadBenchmark tests opt in. Off by default — load tests
// are noisy and slow.
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
System.getProperty("fanoutScalingEvents")?.let { systemProperty("fanoutScalingEvents", it) }
System.getProperty("fanoutScalingSubs")?.let { systemProperty("fanoutScalingSubs", it) }
// NegentropyServerReconcileBenchmark opt-in + sizing.
System.getProperty("negServerBench")?.let { systemProperty("negServerBench", it) }
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
// DeletionSettleBenchmark sizing.
System.getProperty("delBenchN")?.let { systemProperty("delBenchN", it) }
System.getProperty("delBenchK")?.let { systemProperty("delBenchK", it) }
// MirrorSyncThroughputTest sizing + external-source opt-in.
System.getProperty("syncN")?.let { systemProperty("syncN", it) }
System.getProperty("syncExpect")?.let { systemProperty("syncExpect", it) }
System.getProperty("syncSourceUrl")?.let { systemProperty("syncSourceUrl", it) }
System.getProperty("syncLiveIndex")?.let { systemProperty("syncLiveIndex", it) }
System.getProperty("syncBackfillSeconds")?.let { systemProperty("syncBackfillSeconds", it) }
System.getProperty("syncFts")?.let { systemProperty("syncFts", it) }
System.getProperty("syncVerify")?.let { systemProperty("syncVerify", it) }
maxHeapSize = System.getProperty("testHeap") ?: maxHeapSize
// Opt-in JFR profiling (-PnegProfile=/tmp/neg.jfr).
(project.findProperty("negProfile") as? String)?.let {
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")
}
// Show println output from test JVM so the benchmark numbers are
// actually visible without grepping the report XML.
testLogging {
showStandardStreams =
(System.getProperty("runLoadBenchmark") == "true") ||
(System.getProperty("negServerBench") == "1")
events("standard_out")
}
}
dependencies {
api(project(":quartz"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.jackson.module.kotlin)
implementation(libs.kotlinx.serialization.json)
// Outbound WebSockets for the [[mirror]] upstream streams (quartz's
// BasicOkHttpWebSocket transport). Same OkHttp the rest of the repo
// already ships (Apache-2.0).
implementation(libs.okhttp)
// Bundled SQLite driver — Relay's default in-memory EventStore creates
// an in-memory DB at runtime.
implementation(libs.androidx.sqlite.bundled.jvm)
// Ktor server engine + WebSocket plugin so Relay can serve real ws://
// traffic. CIO is the coroutine-based engine — lighter than Netty.
api(libs.ktor.server.core)
api(libs.ktor.server.cio)
api(libs.ktor.server.websockets)
// TOML parsing for the operator config file. Mirrors the section
// layout of nostr-rs-relay's config.toml so existing operators can
// port their configs nearly verbatim.
implementation(libs.fourkoma)
// testFixtures: code in src/testFixtures/kotlin (RelayClientTest +
// synthetic event builders). Not shipped in the production jar but
// exposed to consumers via testImplementation(testFixtures(...)).
testFixturesApi(project(":quartz"))
testFixturesApi(libs.junit)
testFixturesImplementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
testImplementation(libs.okhttp)
}
// ---------------------------------------------------------------------------
// Native distribution (jlink + jpackage)
//
// geode is a long-running server daemon, so unlike a desktop app the useful
// artifacts are a bundled-runtime tarball and native Linux packages an operator
// can drop onto a box. This mirrors the amy CLI's pipeline (cli/build.gradle.kts)
// — geode is the same kind of `application`-plugin JVM module — but geode drags
// in no Compose UI (it depends only on :quartz), so there's no render-stack to
// exclude and no "no Compose" CI assertion to guard.
//
// Outputs land under geode/build/:
// - geode-image/geode/ portable, flat directory (bin/ + lib/ + runtime/ +
// share/ with config.example.toml + geode.service).
// tar this on every OS → geode-<ver>-<fam>-<arch>.tar.gz
// - jpackage/geode_*.deb Debian/Ubuntu package (Linux runners only)
// - jpackage/geode-*.rpm Fedora/RHEL package (Linux runners only)
//
// We build our own flat app-image instead of `jpackage --type app-image` because
// on macOS jpackage buries the binary inside an `.app` bundle — wrong for a CLI-
// launched daemon. The flat tree matches the Linux jpackage layout on every OS.
//
// Release wiring lives in .github/workflows/create-release.yml (build-geode job)
// and geode/Dockerfile (the GHCR image). See the plan at
// geode/plans/2026-07-24-geode-release.md.
// ---------------------------------------------------------------------------
val appVersion: String = project.version.toString()
// RPM rejects dashes in version strings — replace with tilde (~), which RPM
// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0.
val rpmVersion: String = appVersion.replace("-", "~")
val mainJarName: String = "geode-$appVersion.jar"
val mainClassName: String = "com.vitorpamplona.geode.MainKt"
// Minimal JDK 21 module set for geode. Keep this tight — every module adds
// megabytes. geode needs more than the CLI: java.management (Ktor CIO / JVM
// metrics), java.sql (the bundled SQLite driver), java.net.http +
// java.security.jgss (okhttp mirror WebSockets over TLS), jdk.crypto.ec (EC
// used by the TLS handshake), jdk.unsupported (sun.misc.Unsafe, touched by
// coroutines/SQLite/CIO). If a transitive dep needs more, `jlink` succeeds at
// link time but the server fails at runtime with NoClassDefFound — the CI
// dry-run (workflow_dispatch) boots the image to catch that early.
val jlinkModules: String = listOf(
"java.base",
"java.logging",
"java.management",
"java.naming",
"java.net.http",
"java.security.jgss",
"java.sql",
"java.xml",
"jdk.crypto.ec",
"jdk.unsupported",
).joinToString(",")
fun javaToolBin(name: String): Provider<String> =
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.map {
val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name
it.metadata.installationPath.file("bin/$exe").asFile.absolutePath
}
val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime")
val geodeImageDir = layout.buildDirectory.dir("geode-image/geode")
val installLibDir = layout.buildDirectory.dir("install/geode/lib")
val jpackageOutDir = layout.buildDirectory.dir("jpackage")
val jlinkRuntime =
tasks.register<Exec>("jlinkRuntime") {
group = "distribution"
description = "Build a minimal JRE for geode via jlink."
outputs.dir(jlinkRuntimeDir)
val jlinkBin = javaToolBin("jlink")
val outDir = jlinkRuntimeDir
val modules = jlinkModules
doFirst {
// jlink refuses to write into an existing directory.
outDir.get().asFile.deleteRecursively()
executable = jlinkBin.get()
args(
"--add-modules", modules,
"--no-header-files",
"--no-man-pages",
"--strip-debug",
// JDK 21+: --compress <int> is deprecated; use zip-<level>.
"--compress", "zip-6",
"--output", outDir.get().asFile.absolutePath,
)
}
}
// Flat app-image: bin/geode launcher + lib/*.jar + runtime/ (the jlink'd JRE) +
// share/geode/ (config.example.toml + the systemd unit). Cross-platform — the
// release workflow tars this up on every OS.
val geodeImage =
tasks.register<Sync>("geodeImage") {
group = "distribution"
description = "Assemble a portable geode app-image (bin/ + lib/ + runtime/ + share/)."
dependsOn(tasks.named("installDist"), jlinkRuntime)
into(geodeImageDir)
// jars from installDist
from(installLibDir) {
into("lib")
}
// jlink'd JRE
from(jlinkRuntimeDir) {
into("runtime")
}
// Operator seed files: an example config and the systemd unit.
from(layout.projectDirectory.file("config.example.toml")) {
into("share/geode")
}
from(layout.projectDirectory.file("packaging/systemd/geode.service")) {
into("share/geode")
}
val mainJar = mainJarName
val mainClass = mainClassName
val unixLauncher =
"""
#!/bin/sh
# geode launcher — uses the bundled jlink'd JRE so no system Java is required.
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
doLast {
val binDir = geodeImageDir.get().asFile.resolve("bin")
binDir.mkdirs()
val launcher = binDir.resolve("geode")
launcher.writeText(unixLauncher)
launcher.setExecutable(true, false)
}
}
fun registerJpackage(
taskName: String,
type: String,
extraArgs: List<String> = emptyList(),
) = tasks.register<Exec>(taskName) {
group = "distribution"
description = "Run jpackage --type $type for geode."
dependsOn(tasks.named("installDist"), jlinkRuntime)
inputs.dir(installLibDir)
inputs.dir(jlinkRuntimeDir)
outputs.dir(jpackageOutDir)
val jpackageBin = javaToolBin("jpackage")
val inDir = installLibDir
val runtimeDir = jlinkRuntimeDir
val outDir = jpackageOutDir
val versionArg = if (type == "rpm") rpmVersion else appVersion
val extra = extraArgs
doFirst {
outDir.get().asFile.mkdirs()
executable = jpackageBin.get()
args(
"--type", type,
"--name", "geode",
"--app-version", versionArg,
"--vendor", "Amethyst Contributors",
"--description", "geode — a standalone Nostr relay.",
"--input", inDir.get().asFile.absolutePath,
"--runtime-image", runtimeDir.get().asFile.absolutePath,
"--main-jar", mainJarName,
"--main-class", mainClassName,
"--dest", outDir.get().asFile.absolutePath,
)
args(extra)
}
}
// .deb for Debian/Ubuntu. Installs under /opt/geode/ with /opt/geode/bin/geode
// as the launcher. No .desktop entry — geode is a headless daemon. The systemd
// unit + example config are shipped for the operator to wire up (see README).
registerJpackage(
"jpackageDeb",
"deb",
extraArgs = listOf(
"--linux-package-name", "geode",
"--linux-deb-maintainer", "vitor@vitorpamplona.com",
),
)
// .rpm for Fedora/RHEL/openSUSE.
registerJpackage(
"jpackageRpm",
"rpm",
extraArgs = listOf(
"--linux-package-name", "geode",
"--linux-rpm-license-type", "MIT",
),
)