Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
44dac29
Chore: Add the extended native-image CI workflow and the window confi…
b-re-w Oct 4, 2026
65626ce
Chore: Check the Kotlin/Native window sources and that the plugin DSL…
b-re-w Oct 4, 2026
a67366f
Chore: JVM hello sample and cell, pin the window publish commit, fix …
b-re-w Oct 4, 2026
03301e7
Fix: Allow Objective-C action selectors in the Kotlin/Native source c…
b-re-w Oct 4, 2026
9b5649b
Chore: Pin core-extended extended after the window publish merge
b-re-w Oct 4, 2026
15705bc
Merge extended into feat/native-image-ci
b-re-w Oct 4, 2026
8f8f638
Chore: hello-awtfree-link, the smallest AwtFree image that must link …
b-re-w Oct 4, 2026
e9de246
Chore: Build and start the AwtFree link sample in each Package cell
b-re-w Oct 4, 2026
e7da5cc
Merge extended into feat/native-image-ci
b-re-w Oct 4, 2026
8b251e3
Chore: Pin core-extended extended
b-re-w Oct 4, 2026
f4d247e
Fix: Find the NIK home by its release file and publish the JDK probe
b-re-w Oct 4, 2026
9014526
Fix: macOS has no timeout, the AWT hello selects Awt, skip metadata c…
b-re-w Oct 4, 2026
1cedf39
Chore: Drop the placeholder hello, the link sample is the AwtFree gate
b-re-w Oct 4, 2026
bd3a8f2
Fix: Initialize the window upcall tables at build time in an AwtFree …
b-re-w Oct 4, 2026
510dd55
Chore: Pin core-extended extended after #69
b-re-w Oct 4, 2026
26a8ca0
Fix: Link libc++ in the macOS AwtFree image
b-re-w Oct 4, 2026
96573fb
Chore: Run the link sample's main on the first thread on macOS
b-re-w Oct 4, 2026
b4824a1
Fix: Quote the metadata path in the config check step
b-re-w Oct 4, 2026
4c8e64a
Chore: Log the native-image arguments and which options name the main…
b-re-w Oct 4, 2026
d221515
Chore: Temporary diagnostic job for the macOS main thread
b-re-w Oct 4, 2026
4a9826a
Chore: Pin the pump fix, drop the diagnostics
b-re-w Oct 4, 2026
f2757f5
Fix: Config check runs on bash 3.2
b-re-w Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 292 additions & 0 deletions .github/workflows/extended-native-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
# The native-image gate for the extended branch: an AWT-free Compose application packaged as
# one executable on macOS arm64 and Linux x64, then started. There is no paths filter, because
# this may become a required check and a required check that is skipped blocks the merge.
#
# The window modules, skiko's static archive and the plugin are all built here from pinned
# sources. Bump CORE_EXTENDED_REF to take newer window code.
name: Extended native image

on:
pull_request:
branches: [ extended ]
workflow_dispatch:

concurrency:
group: extended-native-image-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
# thisisthepy/compose-multiplatform-core-extended, the commit the window modules and
# extended/skiko come from.
CORE_EXTENDED_REF: 87f67ef9a3c5eff81da9f0999a71addfa6ac7295
PLUGIN_VERSION: 1.11.1-extended-dev
COMPOSE_VERSION: 1.11.1
NIK_BASE: https://github.com/bell-sw/LibericaNIK/releases/download/25.0.4.1+1-25.0.4.1+2

jobs:
# compose-rust and pythonx constraints, kept apart from the standalone samples: the upcall
# table and the absence of runtime reflection matter to them, not to an ordinary Kotlin app.
config-check:
name: "compose-rust/pythonx: window upcall table and no reflection"
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
extended/native-image
.github
- uses: actions/checkout@v4
with:
repository: thisisthepy/compose-multiplatform-core-extended
ref: ${{ env.CORE_EXTENDED_REF }}
path: core-extended
sparse-checkout: extended/window
- name: No runtime reflection, upcalls through the table
run: |
meta=extended/native-image/hello-awtfree/src/main/native-image/reachability-metadata.json
if [ -f "$meta" ]; then
bash extended/native-image/check-native-config.sh core-extended --strict "$meta"
else
echo "hello-awtfree does not exist yet: checking the window sources only"
bash extended/native-image/check-native-config.sh core-extended
fi

plugin-dsl:
name: Plugin DSL additive
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
filter: blob:none
- name: Upstream DSL gains declarations and loses none
run: bash extended/native-image/check-plugin-dsl-additive.sh origin/jb-main

# The plain JVM way to ship, built and started standalone. Independent of the native-image
# cells below, so a JVM regression is not hidden behind a native-image one.
jvm:
name: JVM hello (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- { name: macos-arm64, os: macos-26 }
- { name: linux-x64, os: ubuntu-24.04 }
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Linux packages
if: runner.os == 'Linux'
run: |
sudo apt-get update -q
sudo apt-get install -y -q --no-install-recommends xvfb xauth libgl1-mesa-dri libxtst6 libxrender1 libxi6 fonts-noto-cjk binutils fakeroot
- name: Plugin to mavenLocal
working-directory: gradle-plugins
run: ./gradlew --no-daemon -q :compose:publishToMavenLocal :jdk-version-probe:publishToMavenLocal -Pdeploy.version=${{ env.PLUGIN_VERSION }} -Pcompose.version=${{ env.COMPOSE_VERSION }}
- name: createDistributable
working-directory: extended/samples/hello-jvm
run: ./gradlew --no-daemon createDistributable
- name: Start the packaged app
working-directory: extended/samples/hello-jvm
shell: bash
run: |
set -euo pipefail
launcher=$(find build/compose/binaries -type f -path '*bin/hello-jvm' -o -type f -path '*MacOS/hello-jvm' | head -1)
echo "launcher: $launcher"
png="$RUNNER_TEMP/jvm-self-check.png"
if [ "$RUNNER_OS" = Linux ]; then
HELLO_SELF_CHECK="$png" xvfb-run -a timeout 120 "$launcher"
else
HELLO_SELF_CHECK="$png" perl -e 'alarm 120; exec @ARGV' "$launcher"
fi
test -s "$png"

package:
name: Package (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: macos-arm64
os: macos-26
nik: macos-aarch64
- name: linux-x64
os: ubuntu-24.04
nik: linux-amd64
# Kotlin/Native packaging cells go here. Each one runs check-native-config.sh (already
# scans extended/window/native) and, after linking, `nm` on the executable for
# Objective-C or class-name lookups; see the K/N section of the plugin change list.
# Kotlin/Native packaging cells go here. The K/N window agent adds them: one per
# platform, building the native window modules into an executable and starting it.
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
SAMPLE_AWTFREE: extended/native-image/hello-awtfree
steps:
- uses: actions/checkout@v4

- uses: actions/checkout@v4
with:
repository: thisisthepy/compose-multiplatform-core-extended
ref: ${{ env.CORE_EXTENDED_REF }}
path: core-extended
sparse-checkout: |
extended/window
extended/skiko

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'

- name: Linux packages
if: runner.os == 'Linux'
run: |
sudo apt-get update -q
sudo apt-get install -y -q --no-install-recommends \
build-essential pkg-config xvfb xauth libgl1-mesa-dev libgl1-mesa-dri \
libx11-dev libxext-dev libxi-dev libxrender-dev libxtst-dev libxrandr-dev libxcursor-dev \
libfontconfig1-dev libfreetype-dev libdbus-1-dev zlib1g-dev fonts-noto-cjk jq

- name: Liberica NIK 25 Full
shell: bash
run: |
curl -fsSL -o "$RUNNER_TEMP/nik.tar.gz" "$NIK_BASE/bellsoft-liberica-vm-full-openjdk25.0.4.1+2-25.0.4.1+1-${{ matrix.nik }}.tar.gz"
mkdir -p "$RUNNER_TEMP/nik" && tar xzf "$RUNNER_TEMP/nik.tar.gz" -C "$RUNNER_TEMP/nik"
rel="$(find "$RUNNER_TEMP/nik" -name release -type f | awk '{print length, $0}' | sort -n | head -1 | cut -d' ' -f2-)"
home="$(dirname "$rel")"
echo "GRAALVM_HOME=$home" >> "$GITHUB_ENV"
"$home/bin/native-image" --version

- name: Does the AwtFree sample exist
id: sample
shell: bash
run: |
if [ -f "$SAMPLE_AWTFREE/build.gradle.kts" ]; then echo "awtfree=true" >> "$GITHUB_OUTPUT"; else echo "awtfree=false" >> "$GITHUB_OUTPUT"; fi

# skiko without JAWT, for the AWT-free sample. Keyed on the pinned commit, which pins the
# script and skiko's revision with it.
- name: skiko static archive cache (no JAWT)
id: skiko-cache
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/skiko-static-nojawt
key: skiko-static-nojawt-${{ matrix.name }}-${{ env.CORE_EXTENDED_REF }}

- name: Build skiko static archive (no JAWT)
if: steps.skiko-cache.outputs.cache-hit != 'true'
shell: bash
run: bash core-extended/extended/skiko/build-skiko-static-jvm.sh --no-jawt "$RUNNER_TEMP/skiko-static-nojawt"

# core-extended's window modules to mavenLocal, the way its extended-window-publish
# workflow does it: Amper rejects two modules both named macos in one project, so each
# publication is built from a project of the modules it needs.
- name: Publish the window modules to mavenLocal
shell: bash
working-directory: core-extended/extended/window
run: |
set -euo pipefail
# The module lists are core-extended's own extended-window-publish matrix.
if [ "$RUNNER_OS" = macOS ]; then
modules="common native/macos graalvm/graalvm-macos graalvm/graalvm-linux"
else
modules="common graalvm/graalvm-macos graalvm/graalvm-linux"
fi
cp project.yaml project.yaml.all
printf 'modules:\n' > project.yaml
for m in $modules; do printf ' - %s\n' "$m" >> project.yaml; done
./kotlin publish mavenLocal
cp project.yaml.all project.yaml
find ~/.m2/repository/org/thisisthepy/compose/window -name '*.pom' | sort

- name: Plugin to mavenLocal
working-directory: gradle-plugins
run: ./gradlew --no-daemon -q :compose:publishToMavenLocal :jdk-version-probe:publishToMavenLocal -Pdeploy.version=${{ env.PLUGIN_VERSION }} -Pcompose.version=${{ env.COMPOSE_VERSION }}

# The AwtFree sample: packageNativeImage with the no-JAWT archive, then a headless start.
- name: packageNativeImage (AwtFree sample)
if: steps.sample.outputs.awtfree == 'true'
working-directory: ${{ env.SAMPLE_AWTFREE }}
env:
SKIKO_STATIC: ${{ runner.temp }}/skiko-static-nojawt/out/${{ matrix.name }}
shell: bash
run: ./gradlew --no-daemon --info packageNativeImage

# The window layer link check: a plain AwtFree main that opens one window through the
# extended window module. packageNativeImage compiles and links appkit_window.m or
# x11_window.c and the upcall table, so a link error in the window sources shows here.
- name: packageNativeImage (AwtFree link sample)
working-directory: extended/native-image/hello-awtfree-link
env:
SKIKO_STATIC: ${{ runner.temp }}/skiko-static-nojawt/out/${{ matrix.name }}
CORE_EXTENDED: ${{ github.workspace }}/core-extended
shell: bash
run: ./gradlew --no-daemon --info packageNativeImage

- name: Start the AwtFree link sample
working-directory: extended/native-image/hello-awtfree-link
shell: bash
run: |
set -euo pipefail
exe=$(find build/compose/native-image -maxdepth 2 -type f -perm -u+x | head -1)
single="$RUNNER_TEMP/link"; rm -rf "$single"; mkdir -p "$single"; cp "$exe" "$single/app"
cd "$single"
if [ "$RUNNER_OS" = Linux ]; then
xvfb-run -a timeout 120 ./app | tee out.txt
else
perl -e 'alarm 120; exec @ARGV' ./app | tee out.txt
fi
grep -q "link check ok" out.txt

- name: "compose-rust/pythonx: config check on the generated metadata"
shell: bash
run: |
if [ "${{ steps.sample.outputs.awtfree }}" = true ]; then
bash extended/native-image/check-native-config.sh core-extended --strict "$SAMPLE_AWTFREE/src/main/native-image/reachability-metadata.json"
else
bash extended/native-image/check-native-config.sh core-extended extended/native-image/hello-awtfree-link/src/main/native-image/reachability-metadata.json
fi

- name: Smoke start, Linux (Xvfb)
if: runner.os == 'Linux' && steps.sample.outputs.awtfree == 'true'
shell: bash
run: |
set -euo pipefail
dir="$SAMPLE_AWTFREE"
exe=$(find "$dir/build/compose/native-image" -maxdepth 2 -type f -perm -u+x | head -1)
single="$RUNNER_TEMP/single"; rm -rf "$single"; mkdir -p "$single"; cp "$exe" "$single/app"
cd "$single"
HELLO_SELF_CHECK="$single/self-check.png" xvfb-run -a timeout 120 ./app
test -s "$single/self-check.png"

# No display session on the macOS runner to draw into, so this starts the executable and
# lets it quit on its own through the auto-exit variable, as compose-rust's smoke tests do.
- name: Smoke start, macOS (start and exit)
if: runner.os == 'macOS' && steps.sample.outputs.awtfree == 'true'
shell: bash
run: |
set -euo pipefail
dir="$SAMPLE_AWTFREE"
exe=$(find "$dir/build/compose/native-image" -maxdepth 2 -type f -perm -u+x | head -1)
single="$RUNNER_TEMP/single"; rm -rf "$single"; mkdir -p "$single"; cp "$exe" "$single/app"
cd "$single"
HELLO_SELF_CHECK="$single/self-check.png" perl -e 'alarm 120; exec @ARGV' ./app

- uses: actions/upload-artifact@v4
if: always()
with:
name: native-image-${{ matrix.name }}
path: |
extended/native-image/*/build/tmp/packageNativeImage/**/*.log
extended/native-image/*/build/reports/
if-no-files-found: ignore
81 changes: 81 additions & 0 deletions extended/native-image/check-native-config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Checks that the window layer reaches native code only through the generated upcall table,
# and that the image's configuration has no runtime reflection for it.
#
# Usage: check-native-config.sh <core-extended checkout> [--strict <reachability-metadata.json>...]
#
# Source checks, always:
# - no Class.forName, java.lang.reflect, kotlin.reflect or by-name method lookup in the
# window modules (extended/window, graalvm modules and common);
# - every file that declares a @CEntryPoint also builds a CEntryPointLiteral, so C reaches
# Kotlin through a table the image builder resolves, never through a name lookup.
# Kotlin/Native window modules (extended/window/native): no kotlin.reflect.full, ServiceLoader
# or by-name lookup of classes and selectors.
# Metadata checks, always, for each file given:
# - no reflection or jni entry names a class of org.thisisthepy.compose.window.
# With --strict (the AWT-free image) the metadata must also have:
# - no jni entry other than org.jetbrains.skiko classes (skiko's JNI is linked by stubs).
set -euo pipefail

core="${1:?usage: check-native-config.sh <core-extended checkout> [--strict] [metadata.json...]}"
shift
strict=0
if [[ "${1:-}" == "--strict" ]]; then strict=1; shift; fi

fail=0
note() { echo "FAIL: $*" >&2; fail=1; }

window="$core/extended/window"
[[ -d "$window" ]] || { echo "no $window" >&2; exit 2; }

# Read line by line: macOS ships bash 3.2, which has no mapfile.
sources=()
while IFS= read -r line; do sources+=("$line"); done < <(find "$window" -type f \( -name '*.kt' -o -name '*.java' \) \
-not -path '*/test/*' -not -path '*/build/*' | sort)
echo "window sources checked: ${#sources[@]}"
[[ ${#sources[@]} -gt 0 ]] || { echo "no window sources found" >&2; exit 2; }

pattern='Class\.forName|java\.lang\.reflect|kotlin\.reflect|\.getDeclaredMethod\(|\.getMethod\(|\.getDeclaredField\(|MethodHandles\.lookup|ServiceLoader'
if hits=$(grep -nE "$pattern" "${sources[@]}" | grep -vE '^\S+:[0-9]+:\s*(//|\*|/\*)'); then
note "runtime reflection in the window sources:"; echo "$hits" >&2
fi

entry_files=0
for f in "${sources[@]}"; do
if grep -q '@CEntryPoint' "$f"; then
entry_files=$((entry_files + 1))
grep -q 'CEntryPointLiteral' "$f" || note "$f declares @CEntryPoint but builds no CEntryPointLiteral"
fi
done
echo "files with upcall entry points: $entry_files"
[[ $entry_files -gt 0 ]] || note "no @CEntryPoint found in the window sources; the upcall table is missing"

# Kotlin/Native window modules: C and Objective-C reach Kotlin through staticCFunction or
# @CName entries, and nothing is looked up by name at run time. K/N has no JVM reflection, so
# what is left to forbid is kotlin.reflect.full, ServiceLoader and Objective-C class lookups by name. A selector for a target-action menu item is not one: it names a method of an object the code itself created.
kn_sources=()
while IFS= read -r line; do kn_sources+=("$line"); done < <(find "$window/native" -type f -name '*.kt' \
-not -path '*/test/*' -not -path '*/build/*' 2>/dev/null | sort)
echo "Kotlin/Native window sources checked: ${#kn_sources[@]}"
if [[ ${#kn_sources[@]} -gt 0 ]]; then
kn_pattern='kotlin\.reflect\.full|ServiceLoader|Class\.forName|NSClassFromString|objc_getClass'
if hits=$(grep -nE "$kn_pattern" "${kn_sources[@]}" | grep -vE '^\S+:[0-9]+:\s*(//|\*|/\*)'); then
note "runtime lookup by name in the Kotlin/Native window sources:"; echo "$hits" >&2
fi
fi

for m in "$@"; do
[[ -f "$m" ]] || { note "no metadata file $m"; continue; }
echo "metadata: $m"
bad=$(jq -r '[(.reflection // [])[], (.jni // [])[]] | .[] | (.type // .name // "" | tostring)
| select(test("org\\.thisisthepy\\.compose\\.window"))' "$m")
[[ -z "$bad" ]] || note "reflection or jni entries for window classes in $m: $bad"
if [[ $strict -eq 1 ]]; then
other=$(jq -r '(.jni // [])[] | (.type // .name // "" | tostring)
| select(startswith("org.jetbrains.skiko") | not)' "$m")
[[ -z "$other" ]] || note "jni entries other than skiko's in $m: $other"
fi
done

[[ $fail -eq 0 ]] && echo "native config check passed"
exit $fail
17 changes: 17 additions & 0 deletions extended/native-image/check-plugin-dsl-additive.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# The plugin's upstream DSL stays source compatible: against the upstream branch, the dsl
# directory may gain declarations and may not lose or change public ones.
# Usage: check-plugin-dsl-additive.sh [upstream ref, default origin/jb-main]
# The plugin has no binary-compatibility dump, so this compares declarations in the diff.
set -euo pipefail
base="${1:-origin/jb-main}"
dir=gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl
removed=$(git diff "$base"...HEAD -U0 -- "$dir" | grep -E '^-[^-]' \
| grep -E '^-\s*((public|abstract|open|override|internal|data|sealed|enum|annotation)\s+)*(val|var|fun|class|interface|object)\s' \
| grep -vE '^-\s*(private|internal)\s' || true)
if [[ -n "$removed" ]]; then
echo "FAIL: public DSL declarations removed or changed against $base:" >&2
echo "$removed" >&2
exit 1
fi
echo "upstream DSL additive against $base"
Loading
Loading