Merge pull request #2555 from vitorpamplona/claude/cli-github-actions-deploy-HVNK4
Add native distribution packaging for Amy CLI (jlink + jpackage)
This commit is contained in:
@@ -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/<gid>.mls, line endings
|
||||
# in identity.json).
|
||||
#
|
||||
# Asset naming: amy-<version>-<family>-<arch>.<ext>. 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.
|
||||
|
||||
@@ -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-<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 tars this up on every OS.
|
||||
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" -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<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",
|
||||
),
|
||||
)
|
||||
|
||||
+64
-3
@@ -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-<version>-<family>-<arch>.<ext> (Compose Desktop app)
|
||||
# amy-<version>-<family>-<arch>.<ext> (Amy CLI — cli/ module)
|
||||
#
|
||||
# Contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
|
||||
# <version> = tag stripped of leading 'v' (e.g. "1.08.0")
|
||||
# <family> = macos | windows | linux
|
||||
# <arch> = x64 | arm64
|
||||
@@ -12,7 +15,8 @@
|
||||
#
|
||||
# Usage:
|
||||
# source scripts/asset-name.sh
|
||||
# collect_assets <family> <arch> <version> <dest_dir>
|
||||
# collect_assets <family> <arch> <version> <dest_dir> # desktop
|
||||
# collect_cli_assets <family> <arch> <version> <dest_dir> # 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 <family> <arch> <version> <ext>
|
||||
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 <dest_dir> using the canonical naming scheme.
|
||||
# Usage: collect_assets <family> <arch> <version> <dest_dir>
|
||||
# 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 <dest_dir> using the canonical
|
||||
# amy-<version>-<family>-<arch>.<ext> 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 <family> <arch> <version> <dest_dir>
|
||||
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-<version>-<family>-<arch>.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user