Files
amethyst/cli/build.gradle.kts
T
mstrofnoneandVitor Pamplona cd2ce05ee8 ci: publish windows-arm64 desktop + windows amy/geode release assets
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.
2026-08-05 17:31:52 -04:00

385 lines
16 KiB
Kotlin

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
application
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
resources.srcDir("src/main/resources")
}
test {
kotlin.srcDir("src/test/kotlin")
resources.srcDir("src/test/resources")
}
}
// `resources.srcDir(...)` above re-adds the default resource root, so every resource is registered
// twice (identical source + destination). Pick last-wins instead of failing the copy.
tasks.withType<ProcessResources>().configureEach {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
// BuzzAgentWrapperSyncTest compares the bundled buzz-agent wrappers against their
// tools/ reference copies. The reference tree lives outside this module, so without
// declaring it Gradle calls :cli:test up-to-date after a tools/-only edit — exactly the
// drift the test exists to catch.
tasks.named<Test>("test") {
inputs
.dir(rootProject.layout.projectDirectory.dir("tools/buzz-agent"))
.withPropertyName("buzzAgentWrapperReference")
.withPathSensitivity(PathSensitivity.RELATIVE)
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
// `amy serve` embeds geode (the standalone Ktor relay built on quartz's
// relay-server code). geode depends only on :quartz, never on :amethyst.
implementation(project(":geode"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
implementation(libs.jackson.module.kotlin)
implementation(libs.slf4j.nop)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
// The JVM secp256k1 JNI binding so tests can exercise real signing/verification.
testImplementation(libs.secp256k1.kmp.jni.jvm)
}
// amy is headless. It compiles against zero Compose UI (the Compose deps are
// `implementation` in :commons, so they never reach the CLI compile classpath —
// verified), yet they still ride the *runtime* classpath into the shipped image:
// ~29 MB of Compose desktop render stack, including skiko's native .dylibs that
// needlessly enlarge the macOS notarization surface. None of it is reachable
// from a CLI command, so drop the whole UI layer from the runtime image. Keep
// `androidx.compose.runtime` — snapshot state + the @Stable/@Immutable
// annotations ARE CLI-safe and used by commons models/state holders (see
// commons/ARCHITECTURE.md §1). A regression here is caught by the "no Compose UI
// in the amy image" assertion in .github/workflows/create-release.yml.
configurations.named("runtimeClasspath") {
// skiko (native renderer + its .dylibs) and the Compose UI bytecode layer.
// NB: Compose Multiplatform publishes UI under org.jetbrains.compose.* — only
// `runtime` relocates to androidx.compose.runtime, which we deliberately keep.
exclude(group = "org.jetbrains.skiko")
exclude(group = "org.jetbrains.compose.ui")
exclude(group = "org.jetbrains.compose.foundation")
exclude(group = "org.jetbrains.compose.material")
exclude(group = "org.jetbrains.compose.material3")
exclude(group = "org.jetbrains.compose.animation")
}
application {
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
applicationName = "amy"
// amy is a non-interactive CLI — never spawn AWT GUI threads. Defensive
// against transitive deps that touch ImageIO / Toolkit during image
// upload (see commons/.../service/upload/ImageReencoder.kt). Belt-and-
// braces with the runtime System.setProperty in Main.kt.
applicationDefaultJvmArgs = listOf("-Djava.awt.headless=true")
}
// Inject `LANG=C.UTF-8` (and the matching Windows code page) into the
// launcher scripts when `LANG`/`LC_ALL` aren't already set. Without this,
// running amy under a POSIX/C locale leaves `sun.jnu.encoding` as
// `ANSI_X3.4-1968` (i.e. ASCII) — JEP 400 pins `file.encoding` to UTF-8
// but deliberately leaves `sun.jnu.encoding` tied to the OS locale, and
// it's read by the JVM *before* any `-D` flag has a chance to apply. So
// `-Dsun.jnu.encoding=UTF-8` does nothing; the encoding has to be set in
// the environment that launches Java.
//
// The symptom this fixes: `marmot message react "$gid" "$id" "🍕"` —
// the shell hands the four UTF-8 bytes (F0 9F 8D 95) to the JVM, the
// JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy
// then signs a kind:7 whose `content` is four replacement characters.
// Whitenoise rejects it with "Invalid reaction content".
val patchAmyLauncherCharset by tasks.registering {
val appName = application.applicationName
val startScriptsTask = tasks.named("startScripts")
dependsOn(startScriptsTask)
// Patch the scripts at their source location so installDist (and any
// downstream packaging like jpackage) copies the patched copies.
doLast {
val unixScript = layout.buildDirectory.file("scripts/$appName").get().asFile
if (unixScript.exists()) {
val text = unixScript.readText()
val marker = "# Default LANG to UTF-8 so the JVM picks UTF-8 for sun.jnu.encoding"
if (!text.contains(marker)) {
// Build the snippet with explicit literal `$` characters to avoid
// regex-replacement-string escapes when we splice it in.
val injected = buildString {
append("\n")
append(marker).append("\n")
append("# (POSIX/C locales force ANSI_X3.4-1968, which mangles non-ASCII argv).\n")
append("if [ -z \"")
append("$").append("{LANG-}").append("$").append("{LC_ALL-}")
append("\" ]; then\n")
append(" export LANG=C.UTF-8\n")
append("fi\n")
}
// Insert right after the shebang. Use indexOf+substring instead
// of regex replaceFirst so the `$` chars in `injected` aren't
// mistaken for backreferences.
val nl = text.indexOf('\n')
val patched =
if (nl >= 0 && text.startsWith("#!")) {
text.substring(0, nl + 1) + injected + text.substring(nl + 1)
} else {
injected.trimStart('\n') + text
}
unixScript.writeText(patched)
}
}
val batScript = layout.buildDirectory.file("scripts/$appName.bat").get().asFile
if (batScript.exists()) {
val text = batScript.readText()
val marker = "rem Pin code page to UTF-8 so sun.jnu.encoding picks UTF-8"
if (!text.contains(marker)) {
val injected =
"$marker\r\n" +
"chcp 65001 > NUL 2>&1\r\n"
val anchor = "@if \"%DEBUG%\"==\"\" @echo off"
val idx = text.indexOf(anchor)
if (idx >= 0) {
val afterAnchor = text.indexOf('\n', idx)
if (afterAnchor >= 0) {
val patched =
text.substring(0, afterAnchor + 1) + injected + text.substring(afterAnchor + 1)
batScript.writeText(patched)
}
}
}
}
}
}
tasks.named("installDist") {
dependsOn(patchAmyLauncherCharset)
}
// ---------------------------------------------------------------------------
// Native distribution (jlink + jpackage)
//
// Produces a self-contained `amy` bundle with a minimal jlink'd JRE embedded —
// no JDK required on the user machine. Outputs land under cli/build/:
// - amy-image/amy/ portable, flat directory (bin/ + lib/ + runtime/)
// tar this up on every OS → amy-<ver>-<fam>-<arch>.tar.gz
// - jpackage/amy_*.deb Debian/Ubuntu package (Linux runners only)
// - jpackage/amy-*.rpm Fedora/RHEL package (Linux runners only)
//
// We deliberately build our own app-image instead of using `jpackage --type
// app-image`, because on macOS jpackage produces an `.app` bundle (with the
// binary buried at Contents/MacOS/amy) — awful UX for a CLI. The flat tree we
// build matches the Linux jpackage layout on every OS.
//
// Packaging for release is wired in .github/workflows/create-release.yml.
// See cli/plans/2026-04-21-cli-distribution.md for the overall plan.
// ---------------------------------------------------------------------------
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 = "cli-$appVersion.jar"
val mainClassName: String = "com.vitorpamplona.amethyst.cli.MainKt"
// Minimal JDK 21 module set for amy. Keep this tight — every module adds
// megabytes to the bundle. If a transitive dep needs more, `jlink` fails loudly
// at build time with "module X not found".
val jlinkModules: String = listOf(
"java.base",
"java.logging",
"java.naming",
"java.net.http",
"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 amyImageRoot = layout.buildDirectory.dir("amy-image")
val amyImageDir = layout.buildDirectory.dir("amy-image/amy")
val installLibDir = layout.buildDirectory.dir("install/amy/lib")
val jpackageOutDir = layout.buildDirectory.dir("jpackage")
val jlinkRuntime =
tasks.register<Exec>("jlinkRuntime") {
group = "distribution"
description = "Build a minimal JRE for amy 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/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
// Cross-platform — the release workflow archives this on every OS (tar.gz on
// unix, zip on Windows). We write BOTH a POSIX `amy` shell launcher AND a
// Windows `amy.bat` launcher into `bin/` unconditionally so the same tree is
// runnable on any target after extraction, regardless of which OS built it.
// (The bundled jlink runtime is host-native — you still need to unzip a
// Windows-built image on Windows to actually launch it — but the launcher
// scripts themselves are host-agnostic, which keeps the layout uniform and
// makes ad-hoc cross-machine inspection painless.)
val amyImage =
tasks.register<Sync>("amyImage") {
group = "distribution"
description = "Assemble a portable amy app-image (bin/ + lib/ + runtime/)."
dependsOn(tasks.named("installDist"), jlinkRuntime)
into(amyImageDir)
// jars from installDist
from(installLibDir) {
into("lib")
}
// jlink'd JRE
from(jlinkRuntimeDir) {
into("runtime")
}
val mainJar = mainJarName
val mainClass = mainClassName
val unixLauncher =
"""
#!/bin/sh
# amy launcher — uses the bundled jlink'd JRE so no system Java is required.
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
// Windows launcher. Uses %~dp0 (drive+path of this .bat, always ending in
// a backslash) so it resolves the app root without depending on CWD, then
// execs the bundled JRE against lib\*. `chcp 65001` pins the console to
// UTF-8 so `sun.jnu.encoding` isn't the OS OEM code page — same rationale
// as the installDist launcher patch above. CRLF line endings so cmd.exe
// parses it correctly.
//
// 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. Java tolerates the `..`
// segment but canonicalising once here keeps every classpath entry and
// error message clean (and matches the loose-directory layout users see
// after unzipping the release archive).
val windowsLauncher =
"@echo off\r\n" +
"chcp 65001 > NUL 2>&1\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\" -Djava.awt.headless=true -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
doLast {
val binDir = amyImageDir.get().asFile.resolve("bin")
binDir.mkdirs()
val unix = binDir.resolve("amy")
unix.writeText(unixLauncher)
unix.setExecutable(true, false)
val windows = binDir.resolve("amy.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 amy."
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", "amy",
"--app-version", versionArg,
"--vendor", "Amethyst Contributors",
"--description", "Amethyst CLI — a non-interactive Nostr client.",
"--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/amy/ with /opt/amy/bin/amy as
// the launcher. We intentionally do NOT request --linux-shortcut (no .desktop
// entry for a CLI).
registerJpackage(
"jpackageDeb",
"deb",
extraArgs = listOf(
"--linux-package-name", "amy",
"--linux-deb-maintainer", "vitor@vitorpamplona.com",
),
)
// .rpm for Fedora/RHEL/openSUSE.
registerJpackage(
"jpackageRpm",
"rpm",
extraArgs = listOf(
"--linux-package-name", "amy",
"--linux-rpm-license-type", "MIT",
),
)