mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Follow-up to the linux-arm64 CI leg (feat/release-linux-arm64). Extends the
release matrix to Windows in three places, all on free public-repo hosted
GitHub runners:
* build-desktop: adds windows-11-arm (arm64) alongside the existing
windows-latest (x64). jpackage/jlink on Windows arm64 produce arm64 MSIs
natively; the same packageReleaseMsi + createReleaseDistributable task
list is used unchanged and the portable-archive step already parameterises
on ${{ matrix.arch }}.
* build-cli: adds windows-latest (x64) and windows-11-arm (arm64) legs
running :cli:amyImage. Windows has no jpackageDeb/Rpm and MSI-for-CLI is
deferred (portable zip is the documented Windows install path); the
headless-lib assertion runs unchanged under git-bash. amyImage now emits
both a POSIX `bin/amy` shell launcher AND a Windows `bin/amy.bat`
launcher into the flat image so the tree layout is uniform regardless of
build host. The .bat pins UTF-8 (chcp 65001) for sun.jnu.encoding, same
reason the installDist .bat was already patched.
* build-geode: adds windows-latest + windows-11-arm legs running
:geode:geodeImage. The existing --port smoke test is generalised to pick
bin/geode.bat on Windows; NIP-11 fetch via curl works unchanged under
git-bash on GH windows runners. Same dual-launcher pattern as amy.
scripts/asset-name.sh: collect_cli_assets and collect_geode_assets now
package the flat image as .zip on Windows (7z when available, falling
back to `zip`, then a portable python3 zipfile.ZipFile invocation). Every
other OS continues to use tar.gz. Adds the expected Windows examples to
the header block.
BUILDING.md: mentions the windows-11-arm runner and updates the asset
count in the Release runbook. No asset-naming contract changes — the
existing amethyst-desktop-<v>-windows-<arch>.<ext>, amy-<v>-windows-<arch>.zip,
and geode-<v>-windows-<arch>.zip shapes were already in scope, they just
weren't produced by any CI leg before.
Local validation on macOS arm64 (build host: JDK 21, gradle 9.5.0):
./gradlew :cli:amyImage -> bin/amy + bin/amy.bat both present
./gradlew :geode:geodeImage -> bin/geode + bin/geode.bat both present
./bin/amy --help -> parses (unix launcher unbroken)
./bin/geode --port 17447 -> NIP-11 served, "supported_nips" present
collect_cli_assets windows arm64 ... -> valid .zip with bin/amy.bat
collect_geode_assets windows x64 ... -> valid .zip with bin/geode.bat
actionlint .github/workflows/create-release.yml -> no new findings
Cross-compile is impossible for jlink/jpackage, so end-to-end
Windows-runtime validation still happens on GH CI on the first PR
build; nothing in this change can be verified any harder locally.
367 lines
14 KiB
Kotlin
367 lines
14 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 archives it on every OS (tar.gz on unix, zip on Windows).
|
|
// Both a POSIX shell launcher and a Windows .bat launcher are written so the
|
|
// tree layout is uniform regardless of build host.
|
|
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"
|
|
// Windows launcher: %~dp0 anchors on the .bat's own directory (drive+
|
|
// path, always trailing backslash) so geode.bat works no matter where
|
|
// it's invoked from. CRLF for cmd.exe. We do NOT force UTF-8 here — the
|
|
// relay is a network daemon that logs and speaks JSON over sockets, and
|
|
// its stdout is machine-readable; leaving the console code page alone
|
|
// matches the geode launcher on POSIX which similarly doesn't touch
|
|
// LANG.
|
|
//
|
|
// The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..`
|
|
// out of DIR to the parent directory — same idiom Gradle's own
|
|
// installDist .bat uses to resolve APP_HOME. See the matching comment on
|
|
// the amy launcher for the rationale (log cleanliness, not correctness).
|
|
val windowsLauncher =
|
|
"@echo off\r\n" +
|
|
"setlocal\r\n" +
|
|
"set \"DIR=%~dp0..\"\r\n" +
|
|
"for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" +
|
|
"\"%DIR%\\runtime\\bin\\java.exe\" -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
|
|
|
|
doLast {
|
|
val binDir = geodeImageDir.get().asFile.resolve("bin")
|
|
binDir.mkdirs()
|
|
val unix = binDir.resolve("geode")
|
|
unix.writeText(unixLauncher)
|
|
unix.setExecutable(true, false)
|
|
val windows = binDir.resolve("geode.bat")
|
|
windows.writeText(windowsLauncher)
|
|
}
|
|
}
|
|
|
|
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",
|
|
),
|
|
)
|