diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index d4abc1b4c..0ac241bfb 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -173,6 +173,142 @@ jobs: ls -la dist >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- + # Amy CLI build matrix. Each leg produces a self-contained amy bundle with a + # minimal jlink'd JRE — no system Java required on the user's machine. + # + # amyImage task (all legs): cli/build/amy-image/amy/ → amy-*.tar.gz + # jpackageDeb / jpackageRpm: cli/build/jpackage/amy_*.deb + amy-*.rpm + # + # macOS legs ship only the tarball. We deliberately avoid jpackage --type + # app-image on macOS because it produces an .app bundle (burying the binary + # at Contents/MacOS/amy) — wrong UX for a CLI. + # + # Windows is intentionally deferred — cli/ has not been validated on Windows + # yet (data-dir path handling, file locking on groups/.mls, line endings + # in identity.json). + # + # Asset naming: amy---.. See scripts/asset-name.sh. + # --------------------------------------------------------------------------- + build-cli: + strategy: + fail-fast: false + matrix: + include: + - { os: macos-13, arch: x64, family: macos, tasks: "amyImage" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Resolve tag + version + id: ver + env: + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + TEST_TAG: ${{ github.event.inputs.test_tag || '' }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${TEST_TAG:-v0.0.0-dryrun}" + else + TAG="${GITHUB_REF_NAME}" + fi + VER="${TAG#v}" + TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) + # On dry-run we only require that TOML has a version; on real tag push we require exact match. + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + if [[ "$TOML_VER" != "$VER" ]]; then + echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG" + exit 1 + fi + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + + - name: Install RPM tooling (linux only) + if: matrix.family == 'linux' + run: sudo apt-get update && sudo apt-get install -y rpm fakeroot + + - name: Build amy artifacts + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + max_attempts: 2 + timeout_minutes: 15 + command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + + - name: Collect + rename assets + run: | + set -euo pipefail + # shellcheck source=scripts/asset-name.sh + source scripts/asset-name.sh + collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + + - name: Enforce CLI size budget (200 MB per asset) + run: | + set -euo pipefail + # The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget + # targets < 80 MB, but :commons currently leaks Compose + Skiko as + # transitive deps (~40 MB of unused UI jars). Budget is set to + # 200 MB until commons is split into core + ui modules — track that + # as a follow-up. Until then, this gate just catches pathological + # regressions (e.g. accidental :amethyst dep pulling Android libs). + fail=0 + for f in dist/*; do + if [[ -f "$f" ]]; then + size=$(wc -c < "$f") + mb=$(( size / 1048576 )) + if (( size > 209715200 )); then + echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget" + fail=1 + else + echo "OK: $f — ${mb} MB" + fi + fi + done + [[ "$fail" == 0 ]] + + - name: Classify release + id: classify + run: | + TAG="${{ steps.ver.outputs.tag }}" + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + + - name: Upload to GH Release (skip on dry-run) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: dist/* + tag_name: ${{ steps.ver.outputs.tag }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: false # Android job writes release notes (last-writer-wins race) + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + run: | + echo "### Dry-run: amy ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + ls -la dist >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- # Android build + sign + direct-upload. Logic preserved from previous workflow; # uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset. diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 30b9a910f..55871b14b 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -34,3 +34,187 @@ application { mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") applicationName = "amy" } + +// --------------------------------------------------------------------------- +// 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---.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 = + 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("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 is deprecated; use zip-. + "--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 tars this up on every OS. +val amyImage = + tasks.register("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" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" + """.trimIndent() + "\n" + + doLast { + val binDir = amyImageDir.get().asFile.resolve("bin") + binDir.mkdirs() + val launcher = binDir.resolve("amy") + launcher.writeText(unixLauncher) + launcher.setExecutable(true, false) + } + } + +fun registerJpackage( + taskName: String, + type: String, + extraArgs: List = emptyList(), +) = tasks.register(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", + ), +) diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index eab8f6bed..aac7b3147 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash -# Single source of truth for Amethyst Desktop release asset naming. +# Single source of truth for Amethyst release asset naming. +# +# Two product lines share this contract: +# amethyst-desktop---. (Compose Desktop app) +# amy---. (Amy CLI — cli/ module) # -# Contract: amethyst-desktop---. # = tag stripped of leading 'v' (e.g. "1.08.0") # = macos | windows | linux # = x64 | arm64 @@ -12,7 +15,8 @@ # # Usage: # source scripts/asset-name.sh -# collect_assets +# collect_assets # desktop +# collect_cli_assets # amy CLI # # Expected examples: # amethyst-desktop-1.08.0-macos-x64.dmg @@ -23,6 +27,11 @@ # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage # amethyst-desktop-1.08.0-linux-x64.tar.gz +# amy-1.08.0-macos-arm64.tar.gz +# amy-1.08.0-macos-x64.tar.gz +# amy-1.08.0-linux-x64.tar.gz +# amy-1.08.0-linux-x64.deb +# amy-1.08.0-linux-x64.rpm set -euo pipefail @@ -33,6 +42,13 @@ asset_name() { printf 'amethyst-desktop-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" } +# CLI variant of asset_name — same shape, different product prefix. +# Usage: cli_asset_name +cli_asset_name() { + local family="$1" arch="$2" version="$3" ext="$4" + printf 'amy-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" +} + # Copy + rename build outputs into using the canonical naming scheme. # Usage: collect_assets # Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives). @@ -66,3 +82,48 @@ collect_assets() { done shopt -u nullglob } + +# Copy + rename amy CLI build outputs into using the canonical +# amy---. scheme. +# +# Expected inputs: +# cli/build/amy-image/amy/ flat app-image built by :cli:amyImage +# (bin/amy + lib/*.jar + runtime/) +# cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only) +# cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only) +# +# Usage: collect_cli_assets +collect_cli_assets() { + local family="$1" arch="$2" version="$3" dest="$4" + mkdir -p "$dest" + local abs_dest + abs_dest="$(cd "$dest" && pwd)" + shopt -s nullglob + + # 1. Tar the flat app-image into amy---.tar.gz. + # This is the portable-across-OS asset — macOS runners produce only + # this one. + local app_image="cli/build/amy-image/amy" + if [ -d "$app_image" ]; then + local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi + + # 2. Linux native installers (.deb, .rpm). jpackage writes them directly + # to cli/build/jpackage/ with its own naming, so we re-copy under the + # canonical scheme. + local src base ext dst + for src in \ + cli/build/jpackage/*.deb \ + cli/build/jpackage/*.rpm \ + ; do + [ -f "$src" ] || continue + base="$(basename "$src")" + ext="${base##*.}" + dst="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" "$ext")" + cp "$src" "$dst" + echo "Collected: $dst" + done + shopt -u nullglob +}