From 8974b115b69bc25ebf174e8c9637373c4d206325 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 16:04:21 -0800 Subject: CI: publish the builds as a download site, deploy via Coolify The deployed image is now a small nginx site instead of a bare app tree: the Linux app (with its runtime), a universal jar, the Android APK, SHA256SUMS and a landing page showing the commit and build date. - desktop: universalJar bundles the Skiko natives for Linux, macOS and Windows (Compose's own jar only carries the building OS's). - Dockerfile: the build stage runs tests and builds every artifact; site/assemble.sh lays them out; the final stage is nginx:alpine. - .ci: after the image is built, `main` calls the beta Coolify hook and `release` the production one; other branches only build. - .dockerignore keeps local.properties and secrets out of the build. Co-Authored-By: Claude Sonnet 5.5 --- .ci/ci.cjs | 18 +++++++++++ .ci/ci.ts | 28 +++++++++++++++-- .dockerignore | 5 ++++ Dockerfile | 20 ++++++++----- desktop/build.gradle.kts | 26 ++++++++++++++++ site/assemble.sh | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ site/nginx.conf | 20 +++++++++++++ 7 files changed, 184 insertions(+), 11 deletions(-) create mode 100755 site/assemble.sh create mode 100644 site/nginx.conf diff --git a/.ci/ci.cjs b/.ci/ci.cjs index f7a5dec..f903738 100755 --- a/.ci/ci.cjs +++ b/.ci/ci.cjs @@ -5019,6 +5019,16 @@ var isPipeline = (t) => (0, import_pengueno3.isObject)(t) && "serialJobs" in t & var REGISTRY = "img.liz.coffee"; var NAMESPACE = "emprespresso"; var IMG = "rookery"; +var getReleaseDeployment = (branch) => { + switch (branch) { + case "release": + return "https://plane.liz.coffee/api/v1/deploy?uuid=vzhqmprh2hqbjjcqrzxwkzww&force=false"; + case "main": + return "https://plane.liz.coffee/api/v1/deploy?uuid=0hgnqywenjjcfltl6hoxmro0&force=false"; + default: + return null; + } +}; var getPipeline = () => { const gitHookPipeline = new DefaultGitHookPipelineBuilder(); const branch = gitHookPipeline.getBranch(); @@ -5038,6 +5048,14 @@ var getPipeline = () => { } }; gitHookPipeline.addStage({ parallelJobs: [build] }); + const webhookUrl = getReleaseDeployment(branch); + if (webhookUrl === null) + return gitHookPipeline.build(); + const deploy = { + type: "coolify_webhook.js", + arguments: { webhookUrl } + }; + gitHookPipeline.addStage({ parallelJobs: [deploy] }); return gitHookPipeline.build(); }; var main = () => { diff --git a/.ci/ci.ts b/.ci/ci.ts index 7908462..c0c8597 100644 --- a/.ci/ci.ts +++ b/.ci/ci.ts @@ -1,14 +1,27 @@ #!/usr/bin/env node -import { BuildDockerImageJob, DefaultGitHookPipelineBuilder } from "@emprespresso/ci_model"; +import { BuildDockerImageJob, CoolifyWebhookJob, DefaultGitHookPipelineBuilder } from "@emprespresso/ci_model"; const REGISTRY = "img.liz.coffee"; const NAMESPACE = "emprespresso"; const IMG = "rookery"; +/** Coolify deploy hooks: `release` is the public download site, `main` is the beta one. Other branches only build. */ +const getReleaseDeployment = (branch: string) => { + switch (branch) { + case "release": + return "https://plane.liz.coffee/api/v1/deploy?uuid=vzhqmprh2hqbjjcqrzxwkzww&force=false"; + case "main": + return "https://plane.liz.coffee/api/v1/deploy?uuid=0hgnqywenjjcfltl6hoxmro0&force=false"; + default: + return null; + } +}; + /** - * Every push: build the Dockerfile's `rookery` target, which runs the whole test suite - * (`gradlew check`) and packages the desktop app. A failing test fails the image build. + * Every push builds the Dockerfile's `rookery` target: the test suite runs, the apps are built, and the + * result is a small web server with a download page (a failing test fails the image build). Pushes to + * `main` and `release` then tell Coolify to pull the new image. */ const getPipeline = () => { const gitHookPipeline = new DefaultGitHookPipelineBuilder(); @@ -30,6 +43,15 @@ const getPipeline = () => { }; gitHookPipeline.addStage({ parallelJobs: [build] }); + const webhookUrl = getReleaseDeployment(branch); + if (webhookUrl === null) return gitHookPipeline.build(); + + const deploy: CoolifyWebhookJob = { + type: "coolify_webhook.js", + arguments: { webhookUrl }, + }; + gitHookPipeline.addStage({ parallelJobs: [deploy] }); + return gitHookPipeline.build(); }; diff --git a/.dockerignore b/.dockerignore index d02da12..698688f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,8 @@ **/build .ci/node_modules .ci/dist +local.properties +.env +.env.* +*_secret +*.secret diff --git a/Dockerfile b/Dockerfile index 30c099e..84e02b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Build + test stage: a failing test fails the image build, which is what lizCI checks. +# Build stage: tests and every download. A failing test fails the image build, which is what lizCI checks. FROM eclipse-temurin:21-jdk AS build WORKDIR /src # Android SDK: core and ui have Android targets, so Gradle needs one even for the desktop build. @@ -16,11 +16,15 @@ COPY desktop/build.gradle.kts desktop/ COPY android/build.gradle.kts android/ RUN ./gradlew --no-daemon -q help COPY . . -RUN ./gradlew --no-daemon :core:jvmTest :desktop:createDistributable - -# Artifact image: just the packaged Linux desktop app (with its bundled runtime). -FROM alpine:3.20 AS rookery +# Tests, then every download: the Linux app (with its runtime), the universal jar and the Android APK. +RUN ./gradlew --no-daemon :core:jvmTest :desktop:createDistributable :desktop:universalJar :android:assembleDebug +# The commit goes in last, so a new commit doesn't redo the build above's cache-able layers. ARG COMMIT=unknown -COPY --from=build /src/desktop/build/compose/binaries/main/app/rookery /opt/rookery -RUN printf '%s' "$COMMIT" > /opt/rookery/commit -CMD ["ls", "/opt/rookery"] +RUN sh site/assemble.sh /site "$COMMIT" + +# The deployed image: a static download page and the files it links to. +FROM nginx:alpine AS rookery +COPY site/nginx.conf /etc/nginx/conf.d/default.conf +RUN rm -f /usr/share/nginx/html/index.html /usr/share/nginx/html/50x.html +COPY --from=build /site /usr/share/nginx/html +EXPOSE 80 diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 2705014..d72a319 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -41,3 +41,29 @@ tasks.withType().configureEach { environment.putAll(System.getenv().filterKeys { it.startsWith("XDG_") || it.startsWith("ROOKERY_") }) standardInput = System.`in` // so `--cube monitor` can take commands } + +// One jar that runs anywhere: the app plus the native graphics libraries for every desktop platform. +// (Compose's own uber jar only carries the natives of the machine that built it.) +val universal: Configuration by configurations.creating { extendsFrom(configurations.runtimeClasspath.get()) } +dependencies { + universal(compose.desktop.linux_x64) + universal(compose.desktop.linux_arm64) + universal(compose.desktop.macos_x64) + universal(compose.desktop.macos_arm64) + universal(compose.desktop.windows_x64) +} + +interface ArchiveServices { @get:javax.inject.Inject val archives: ArchiveOperations } +val archives = objects.newInstance().archives // an injected service, so the configuration cache can store it + +tasks.register("universalJar") { + group = "distribution" + archiveBaseName = "rookery" + archiveClassifier = "universal" + manifest { attributes["Main-Class"] = "coffee.liz.rookery.desktop.MainKt" } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(sourceSets.main.get().output) + val unzip = archives // a local, so the closure below captures the service and not the whole build script + from(universal.elements.map { files -> files.filter { it.asFile.name.endsWith(".jar") }.map { unzip.zipTree(it.asFile) } }) + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/INDEX.LIST") +} diff --git a/site/assemble.sh b/site/assemble.sh new file mode 100755 index 0000000..aed5b27 --- /dev/null +++ b/site/assemble.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# Lays out the download site from whatever the Gradle build produced. +# usage: site/assemble.sh [commit] +# Anything that wasn't built is simply left off the page, so this also works on a partial local build. +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +out=${1:?usage: assemble.sh [commit]} +commit=${2:-unknown} +mkdir -p "$out" + +case $(uname -m) in x86_64|amd64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) arch=$(uname -m) ;; esac +sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } +size() { du -h "$1" | cut -f1 | tr -d ' '; } + +apk=$root/android/build/outputs/apk/debug/android-debug.apk +jar=$root/desktop/build/libs/rookery-universal.jar +app=$root/desktop/build/compose/binaries/main/app/rookery + +[ -f "$apk" ] && cp "$apk" "$out/rookery-android.apk" +[ -f "$jar" ] && cp "$jar" "$out/rookery.jar" +[ -d "$app" ] && tar -C "$(dirname "$app")" -czf "$out/rookery-linux-$arch.tar.gz" rookery + +printf '%s\n' "$commit" > "$out/commit.txt" +(cd "$out" && sha rookery* > SHA256SUMS) + +row() { # file, description + [ -f "$out/$1" ] && printf '%s%s%s\n' "$1" "$1" "$2" "$(size "$out/$1")" + return 0 +} + +short=$(printf '%.7s' "$commit") +if [ "$commit" = unknown ]; then rev="$short"; else rev="$short"; fi +built=$(date -u +"%Y-%m-%d %H:%M UTC") + +cat > "$out/index.html" < + + + + +rookery + + + +

rookery

+

speedcubing timer and blindfold trainer. desktop and android.

+
+

downloads

+ +$(row rookery-android.apk "Android 8+, debug-signed") +$(row "rookery-linux-$arch.tar.gz" "Linux $arch, runtime included") +$(row rookery.jar "Java 21; Linux, macOS, Windows") +
+
    +
  • apk: allow installing from this source when asked.
  • +
  • tar.gz: extract, then run rookery/bin/rookery.
  • +
  • jar: java -jar rookery.jar
  • +
  • SHA256SUMS
  • +
+
+

build $rev, $built

+ + +HTML + +echo "site: $(ls "$out" | tr '\n' ' ')" diff --git a/site/nginx.conf b/site/nginx.conf new file mode 100644 index 0000000..310d68d --- /dev/null +++ b/site/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # a new build replaces these files in place, so have clients check + add_header Cache-Control "no-cache"; + + location ~ \.apk$ { + default_type application/vnd.android.package-archive; + add_header Cache-Control "no-cache"; + } + location = /SHA256SUMS { + default_type text/plain; + add_header Cache-Control "no-cache"; + } + location / { + try_files $uri $uri/ =404; + } +} -- cgit v1.3