From cd2ce05ee8b0c2a7f3faf4ff91cf673dc64a6425 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Wed, 5 Aug 2026 15:45:39 +1000 Subject: [PATCH] ci: publish windows-arm64 desktop + windows amy/geode release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--windows-., amy--windows-.zip, and geode--windows-.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. --- .github/workflows/create-release.yml | 49 +++++++++++---- BUILDING.md | 19 +++--- cli/build.gradle.kts | 37 ++++++++++-- geode/build.gradle.kts | 30 ++++++++-- scripts/asset-name.sh | 89 +++++++++++++++++++++++----- 5 files changed, 185 insertions(+), 39 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0dfea9bfcd..ec8dd01ac0 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -44,11 +44,14 @@ jobs: matrix: # Linux legs run on x64 and arm64 GitHub-hosted runners (the # ubuntu-24.04-arm label is a standard free public-repo runner as of - # early 2025). jpackage / jlink / Compose Multiplatform 1.11 all - # produce host-native artifacts — no cross-compilation needed. + # early 2025). Windows arm64 uses windows-11-arm, added to the free + # public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64). + # jpackage / jlink / Compose Multiplatform 1.11 all produce + # host-native artifacts — no cross-compilation needed. include: - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } @@ -369,9 +372,17 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } - - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + # Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types + # and jpackageMsi for a CLI is deferred (the portable zip is the + # documented Windows install path). The launcher script writes both + # `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs + # under bash on GH windows runners (git-bash is on PATH). collect_cli_assets + # zips the image on Windows instead of tar.gz. + - { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -619,9 +630,16 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } - - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + # Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is + # deferred (portable zip covers the primary use — operators still + # deploy geode via the Docker image or the tarball on Linux). The + # image writes both `bin/geode` (sh) and `bin/geode.bat`, and the + # smoke test below runs under bash on the windows runner. + - { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -676,12 +694,23 @@ jobs: # module list is complete for the real relay path (Ktor CIO + SQLite + # NIP-11 serialization) — a too-tight module list links fine but fails # here with NoClassDefFound instead of on an operator's machine. + # + # On the Windows legs we invoke bin/geode.bat instead of bin/geode. The + # tmp path also differs between git-bash on Windows (which resolves /tmp + # to a mingw path that curl -o accepts) and POSIX runners; kept identical + # because the workflow's `defaults.run.shell: bash` uses git-bash on + # Windows and /tmp is a valid mingw path there. - name: Smoke-test the geode image run: | set -euo pipefail IMG="geode/build/geode-image/geode" - "$IMG/bin/geode" --version - "$IMG/bin/geode" --port 17447 & + if [[ "${{ matrix.family }}" == "windows" ]]; then + LAUNCHER="$IMG/bin/geode.bat" + else + LAUNCHER="$IMG/bin/geode" + fi + "$LAUNCHER" --version + "$LAUNCHER" --port 17447 & PID=$! ok=0 for i in $(seq 1 20); do diff --git a/BUILDING.md b/BUILDING.md index b931926c00..680d4caad3 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -35,7 +35,9 @@ All platforms: Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) -- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` +- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`. + Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner — + jpackage on Windows arm64 produces arm64 MSIs natively; no cross-compilation. - **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + `flatpak-builder` for the Flatpak bundle (see @@ -328,14 +330,17 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify** — the GH Release should hold **31 assets**: - - **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`, - `AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS - DMG** — `jpackage` cannot cross-compile and no Intel runner leg is - configured, so macOS ships arm64-only. +4. **Verify** — the GH Release should hold **37 assets**: + - **10 desktop** — `dmg` (macOS arm64); `msi` + `zip` per Windows arch + (x64 and arm64, 4 files); `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` + for Linux x64+arm64 (5 formats × 2 arches shipped as one merged set of + 5 in the current layout — see the previous release for the exact + enumeration). There is **no Intel/x64 macOS DMG** — `jpackage` cannot + cross-compile and no Intel runner leg is configured, so macOS ships + arm64-only. - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid `.apks` set built for Accrescent. - - **5 amy** + **5 geode** bundles. + - **7 amy** + **7 geode** bundles (5 unix + 2 Windows portable zips each). - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - Android flow unchanged diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index d92227363e..6aa88d026f 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -254,7 +254,14 @@ val jlinkRuntime = } // Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE). -// Cross-platform — the release workflow tars this up on every OS. +// 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("amyImage") { group = "distribution" @@ -282,13 +289,35 @@ val amyImage = 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 launcher = binDir.resolve("amy") - launcher.writeText(unixLauncher) - launcher.setExecutable(true, false) + val unix = binDir.resolve("amy") + unix.writeText(unixLauncher) + unix.setExecutable(true, false) + val windows = binDir.resolve("amy.bat") + windows.writeText(windowsLauncher) } } diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 70254af99c..2c8efa600f 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -236,7 +236,9 @@ val jlinkRuntime = // 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. +// 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("geodeImage") { group = "distribution" @@ -271,13 +273,33 @@ val geodeImage = 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 launcher = binDir.resolve("geode") - launcher.writeText(unixLauncher) - launcher.setExecutable(true, false) + val unix = binDir.resolve("geode") + unix.writeText(unixLauncher) + unix.setExecutable(true, false) + val windows = binDir.resolve("geode.bat") + windows.writeText(windowsLauncher) } } diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index ee7b2bda98..100e7247b2 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -24,6 +24,8 @@ # amethyst-desktop-1.08.0-macos-arm64.dmg # amethyst-desktop-1.08.0-windows-x64.msi # amethyst-desktop-1.08.0-windows-x64.zip +# amethyst-desktop-1.08.0-windows-arm64.msi +# amethyst-desktop-1.08.0-windows-arm64.zip # amethyst-desktop-1.08.0-linux-x64.deb # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage @@ -42,6 +44,8 @@ # amy-1.08.0-linux-arm64.tar.gz # amy-1.08.0-linux-arm64.deb # amy-1.08.0-linux-arm64.rpm +# amy-1.08.0-windows-x64.zip +# amy-1.08.0-windows-arm64.zip # geode-1.08.0-macos-arm64.tar.gz # geode-1.08.0-linux-x64.tar.gz # geode-1.08.0-linux-x64.deb @@ -49,6 +53,8 @@ # geode-1.08.0-linux-arm64.tar.gz # geode-1.08.0-linux-arm64.deb # geode-1.08.0-linux-arm64.rpm +# geode-1.08.0-windows-x64.zip +# geode-1.08.0-windows-arm64.zip # # Two assets break the family/arch shape on purpose: the no-JRE jar bundles for # Homebrew-core are pure JVM bytecode (no bundled runtime), so a single @@ -121,10 +127,14 @@ collect_assets() { # # Expected inputs: # cli/build/amy-image/amy/ flat app-image built by :cli:amyImage -# (bin/amy + lib/*.jar + runtime/) +# (bin/amy + bin/amy.bat + lib/*.jar + runtime/) # cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only) # cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only) # +# On Windows the flat image is packaged as .zip (native archive format, +# preserves file layout without requiring a tar tool at install time). Every +# other OS uses tar.gz. +# # Usage: collect_cli_assets collect_cli_assets() { local family="$1" arch="$2" version="$3" dest="$4" @@ -133,14 +143,39 @@ collect_cli_assets() { 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. + # 1. Archive the flat app-image into amy---.. + # macOS runners produce only this one. Windows uses .zip; every other + # OS uses tar.gz. 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" + if [ "$family" = "windows" ]; then + local zipfile="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" zip)" + # Prefer 7z when available (bash+7zip is standard on GH windows runners), + # else fall back to a portable python3 zipfile. `zip` itself is not always + # present on GH windows runners. + if command -v 7z >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && 7z a -tzip "$zipfile" "$(basename "$app_image")/" >/dev/null ) + elif command -v zip >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && zip -qr "$zipfile" "$(basename "$app_image")" ) + else + python3 - "$app_image" "$zipfile" <<'PY' +import os, sys, zipfile +src, dst = sys.argv[1], sys.argv[2] +root = os.path.dirname(src) +base = os.path.basename(src) +with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf: + for dirpath, _dirs, files in os.walk(src): + for f in files: + p = os.path.join(dirpath, f) + zf.write(p, os.path.relpath(p, root)) +PY + fi + echo "Collected: $zipfile" + else + 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 fi # 2. Linux native installers (.deb, .rpm). jpackage writes them directly @@ -166,10 +201,14 @@ collect_cli_assets() { # # Expected inputs: # geode/build/geode-image/geode/ flat app-image built by :geode:geodeImage -# (bin/geode + lib/*.jar + runtime/ + share/) +# (bin/geode + bin/geode.bat + lib/*.jar +# + runtime/ + share/) # geode/build/jpackage/*.deb from :geode:jpackageDeb (Linux only) # geode/build/jpackage/*.rpm from :geode:jpackageRpm (Linux only) # +# On Windows the flat image is packaged as .zip; every other OS uses tar.gz. +# See the matching comment in collect_cli_assets for the tool-selection order. +# # Usage: collect_geode_assets collect_geode_assets() { local family="$1" arch="$2" version="$3" dest="$4" @@ -178,14 +217,36 @@ collect_geode_assets() { abs_dest="$(cd "$dest" && pwd)" shopt -s nullglob - # 1. Tar the flat app-image into geode---.tar.gz. - # This is the portable-across-OS asset — macOS runners produce only - # this one. + # 1. Archive the flat app-image into geode---.. + # macOS runners produce only this one. Windows uses .zip; every other + # OS uses tar.gz. local app_image="geode/build/geode-image/geode" if [ -d "$app_image" ]; then - local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)" - ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) - echo "Collected: $tarball" + if [ "$family" = "windows" ]; then + local zipfile="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" zip)" + if command -v 7z >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && 7z a -tzip "$zipfile" "$(basename "$app_image")/" >/dev/null ) + elif command -v zip >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && zip -qr "$zipfile" "$(basename "$app_image")" ) + else + python3 - "$app_image" "$zipfile" <<'PY' +import os, sys, zipfile +src, dst = sys.argv[1], sys.argv[2] +root = os.path.dirname(src) +base = os.path.basename(src) +with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf: + for dirpath, _dirs, files in os.walk(src): + for f in files: + p = os.path.join(dirpath, f) + zf.write(p, os.path.relpath(p, root)) +PY + fi + echo "Collected: $zipfile" + else + local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi fi # 2. Linux native installers (.deb, .rpm). jpackage writes them directly