Skip to content

Run Native Pipeline #2473

Run Native Pipeline

Run Native Pipeline #2473

Workflow file for this run

name: Run Native Pipeline
on:
workflow_dispatch:
inputs:
run_name:
description: "Provide a name for the run"
required: false
default: ""
mendix_version:
description: "Provide the SP version to be used (e.g., 10.14.0.43709) - has to be a released version (Default: latest from Mendix versions.json)"
required: false
default: ""
nt_branch:
description: "Native Template branch/tag to use (Leave empty to auto-select based on Mendix version)"
default: ""
required: false
type: string
test_project_ref:
description: "mendix/Native-Mobile-Resources ref (commit SHA) to build against (leave empty for the pinned default)"
default: ""
required: false
type: string
LOCAL_TEST_ARTIFACT_RUN_ID:
description: "Workflow run ID for local artifact download (leave empty for normal CI). Use the full run ID from the URL, not the run number displayed in UI."
required: false
default: ""
update_baselines:
description: "Capture new screenshot baselines instead of asserting. Tests still run (to produce screenshots) but visual mismatches won't fail the run; download the *-screenshots-* artifacts and commit them under maestro/images/expected/."
required: false
default: false
type: boolean
workspace:
description: "Select a widget to test (Default will run all)"
required: true
default: "*-native"
type: choice
# NOTE: This list must be kept in sync with packages/pluggableWidgets/*-native folders
# The determine-widget-scope.sh script automatically discovers widgets, but GitHub Actions
# doesn't support dynamic dropdown options, so this list must be updated manually when
# adding or removing widgets.
options:
- "*-native"
- js-actions
- accordion-native
- activity-indicator-native
- animation-native
- app-events-native
- background-gradient-native
- background-image-native
- badge-native
- bar-chart-native
- barcode-scanner-native
- bottom-sheet-native
- carousel-native
- color-picker-native
- column-chart-native
- feedback-native
- floating-action-button-native
- gallery-native
- gallery-text-filter-native
- image-native
- intro-screen-native
- line-chart-native
- listview-swipe-native
- maps-native
- notifications-native
- pie-doughnut-chart-native
- popup-menu-native
- progress-bar-native
- progress-circle-native
- qr-code-native
- radio-buttons-native
- range-slider-native
- rating-native
- repeater-native
- safe-area-view-native
- signature-native
- slider-native
- switch-native
- toggle-buttons-native
- video-player-native
- web-view-native
# Trigger on PR
pull_request:
branches:
- main
# Surface capture mode in the run title (workflow_dispatch inputs aren't shown in the UI),
# otherwise fall back to the provided run_name / default. inputs.update_baselines is the
# typed boolean from workflow_dispatch (null on pull_request).
run-name: ${{ inputs.update_baselines && '🔄 Capture screenshot baselines' || (github.event.inputs.run_name || 'Run Native Pipeline') }}
# Cancel superseded runs for the same PR/branch; a new push should not race an in-flight pipeline.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
LOCAL_TEST_ARTIFACT_RUN_ID: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID || '' }}
# Opt out of Maestro's anonymous analytics (avoids a network call, keeps runs deterministic).
MAESTRO_CLI_NO_ANALYTICS: "1"
# When "true" (set via the update_baselines dispatch input), the maestro run steps tolerate
# assertScreenshot failures so a capture run can produce fresh baselines without going red.
UPDATE_BASELINES: ${{ github.event.inputs.update_baselines || 'false' }}
# Pin the test project (mendix/Native-Mobile-Resources) to a SHA for reproducibility instead
# of tracking a moving `main`. Single source of truth — bump deliberately. The repo publishes
# no tags, so a commit SHA is the only stable ref. Override per run via the dispatch input.
NATIVE_MOBILE_RESOURCES_REF: ${{ github.event.inputs.test_project_ref || 'f0e7c2278578022cc742f82a087e6c4fff2f7159' }}
permissions:
packages: write
jobs:
scope:
runs-on: ubuntu-26.04
outputs:
scope: ${{ steps.scope.outputs.scope }}
widgets: ${{ steps.scope.outputs.widgets }}
widgets_to_test: ${{ steps.scope.outputs.widgets_to_test }}
js_actions_changed: ${{ steps.scope.outputs.js_actions_changed }}
full_build: ${{ steps.scope.outputs.full_build }}
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Full history so the scope script can diff against the PR base merge-base
# (covers every commit in the PR, not just the latest).
fetch-depth: 0
- name: "Determine scope"
id: scope
# Pass the PR base SHA (empty on non-PR events → the script's dispatch path handles it).
run: |
chmod +x ./.github/scripts/determine-widget-scope.sh
./.github/scripts/determine-widget-scope.sh "${{ github.event_name }}" "${{ github.event.inputs.workspace }}" "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}"
- name: "Debug Scope Output"
run: |
echo "Scope is: ${{ steps.scope.outputs.scope }}"
echo "Widgets to build: ${{ steps.scope.outputs.widgets }}"
echo "Widgets to test: ${{ steps.scope.outputs.widgets_to_test }}"
echo "JS actions changed: ${{ steps.scope.outputs.js_actions_changed }}"
mendix-version:
runs-on: ubuntu-26.04
outputs:
mendix_version: ${{ steps.set-mendix-version.outputs.MENDIX_VERSION }}
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set Mendix version
id: set-mendix-version
run: |
if [[ -n "${{ github.event.inputs.mendix_version }}" ]]; then
echo "MENDIX_VERSION=${{ github.event.inputs.mendix_version }}" >> "$GITHUB_OUTPUT"
else
MENDIX_VERSION=$(jq -r '.latest' configs/e2e/mendix-versions.json)
echo "MENDIX_VERSION=$MENDIX_VERSION" >> "$GITHUB_OUTPUT"
fi
- name: "Debug Mendix Version"
run: |
echo "Mendix Version: ${{ steps.set-mendix-version.outputs.MENDIX_VERSION }}"
determine-nt-version:
needs: [mendix-version]
runs-on: ubuntu-26.04
outputs:
nt_branch: ${{ steps.set-output.outputs.nt_branch }}
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "Check if nt_branch was specified"
id: check-input
run: |
if [[ -n "${{ github.event.inputs.nt_branch }}" ]]; then
echo "Using specified nt_branch: ${{ github.event.inputs.nt_branch }}"
echo "nt_branch=${{ github.event.inputs.nt_branch }}" >> "$GITHUB_OUTPUT"
echo "source=input" >> "$GITHUB_OUTPUT"
else
echo "No nt_branch specified, will determine from mendix_version"
echo "source=auto" >> "$GITHUB_OUTPUT"
fi
- name: "Download mendix_version.json from native-template repo"
if: steps.check-input.outputs.source == 'auto'
run: |
curl -s -o mendix_version.json https://raw.githubusercontent.com/mendix/native-template/master/mendix_version.json
cat mendix_version.json
- name: "Determine Native Template version based on Mendix version"
if: steps.check-input.outputs.source == 'auto'
id: determine-nt-branch
# Node (preinstalled on the runner) — no setup-python / pip; the script has no deps.
run: node ./.github/scripts/determine-nt-version.mjs "${{ needs.mendix-version.outputs.mendix_version }}"
- name: "Set output nt_branch"
id: set-output
run: |
if [[ "${{ steps.check-input.outputs.source }}" == "input" ]]; then
echo "nt_branch=${{ github.event.inputs.nt_branch }}" >> "$GITHUB_OUTPUT"
else
echo "nt_branch=${{ steps.determine-nt-branch.outputs.nt_branch }}" >> "$GITHUB_OUTPUT"
fi
- name: "Debug final branch output"
run: |
echo "Final nt_branch value: ${{ steps.set-output.outputs.nt_branch }}"
docker-images:
needs: [mendix-version]
runs-on: ubuntu-26.04
steps:
- name: "Login to GitHub Container Registry"
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Check if docker image already exists"
run: |
docker manifest inspect ghcr.io/${{ github.repository }}/mxbuild:${{ needs.mendix-version.outputs.mendix_version }} || EXIT_CODE=$?
echo "IMAGE_MISSING=$EXIT_CODE" >> "$GITHUB_ENV"
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: ${{ env.IMAGE_MISSING != 0 }}
- name: "Build mxbuild image"
if: ${{ env.IMAGE_MISSING != 0 }}
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
file: ./.github/scripts/mxbuild.Dockerfile
context: ./.github/scripts
build-args: |
MENDIX_VERSION=${{ needs.mendix-version.outputs.mendix_version }}
push: true
tags: ghcr.io/${{ github.repository }}/mxbuild:${{ needs.mendix-version.outputs.mendix_version }}
resources:
needs: [scope, mendix-version]
runs-on: ubuntu-26.04
permissions:
packages: read
contents: read
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: "Setup Node and dependencies (with pnpm store cache)"
uses: ./.github/actions/setup-node-with-cache
- name: "Restore built resources (dist) cache"
id: resources-cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
# Built widget .mpk / JS-action dist are reproducible from source + toolchain, so
# key on widget/JS-action src + their package.json + the lockfile. An infra-only
# change (no src touched) keeps the same key → exact hit → skip the rebuild below.
path: |
packages/pluggableWidgets/*/dist
packages/jsActions/*/dist
key: resources-dist-v1-${{ hashFiles('packages/pluggableWidgets/*/src/**', 'packages/jsActions/*/src/**', 'packages/pluggableWidgets/*/package.json', 'packages/jsActions/*/package.json', 'pnpm-lock.yaml') }}
- name: "Build resources"
# On a full build with an exact cache hit every widget's dist is already current, so
# skip the slow rollup/babel rebuild. Otherwise build the scoped widgets + JS actions
# in one pnpm invocation (multiple --filter targets run concurrently).
run: |
if [ "${{ needs.scope.outputs.full_build }}" = "true" ] && [ "${{ steps.resources-cache.outputs.cache-hit }}" = "true" ]; then
echo "Full build with cached resources — skipping rebuild."
exit 0
fi
filters=()
if [ "${{ needs.scope.outputs.js_actions_changed }}" = "true" ] || \
[ "${{ github.event.inputs.workspace }}" = "js-actions" ] || \
[ "${{ github.event.inputs.workspace }}" = "*-native" ] || \
[ "${{ github.event_name }}" = "schedule" ]; then
filters+=(--filter=mobile-resources-native --filter=nanoflow-actions-native)
fi
while read -r w; do
[ -n "$w" ] && filters+=(--filter="$w")
done < <(echo '${{ needs.scope.outputs.widgets }}' | jq -r '.[]')
if [ ${#filters[@]} -gt 0 ]; then
echo "Building: ${filters[*]}"
pnpm "${filters[@]}" run build
else
echo "Nothing to build."
fi
- name: "Unit test"
run: pnpm -r --filter="${{ needs.scope.outputs.scope }}" run test
- name: "Save built resources (dist) cache"
# Only after a full build (every widget built), so a partial build never populates the
# shared cache with an incomplete dist set under a key a later full build could hit.
if: ${{ needs.scope.outputs.full_build == 'true' && steps.resources-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
packages/pluggableWidgets/*/dist
packages/jsActions/*/dist
key: ${{ steps.resources-cache.outputs.cache-primary-key }}
- name: "Write resources manifest"
# Guarantees the resources artifact always contains at least one file, so the
# download in the `project` job never fails. A widgets-PR whose only changes are
# under e2e/ builds nothing (those widgets test against the baseline project), which
# would otherwise leave the artifact empty → not created → project download error.
run: |
{
echo "built widgets: ${{ needs.scope.outputs.widgets }}"
echo "tested widgets: ${{ needs.scope.outputs.widgets_to_test }}"
echo "js_actions_changed: ${{ needs.scope.outputs.js_actions_changed }}"
echo "full_build: ${{ needs.scope.outputs.full_build }}"
} > resources-manifest.txt
- name: "Upload JS actions resources artifact"
if: ${{ github.event.inputs.workspace == 'js-actions' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: resources
path: |
resources-manifest.txt
packages/jsActions/mobile-resources-native/dist/**/*
packages/jsActions/nanoflow-actions-native/dist/**/*
- name: "Upload widget and JS actions resources artifact"
if: ${{ github.event.inputs.workspace != 'js-actions' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: resources
path: |
resources-manifest.txt
packages/pluggableWidgets/**/dist/*/*.mpk
packages/jsActions/mobile-resources-native/dist/**/*
packages/jsActions/nanoflow-actions-native/dist/**/*
project:
needs: [resources, mendix-version]
runs-on: ubuntu-26.04
container:
image: ghcr.io/${{ github.repository }}/mxbuild:${{ needs.mendix-version.outputs.mendix_version }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: "Install curl and unzip"
run: |
apt update
apt install -y curl unzip
- name: "Download test project"
# Pinned to a SHA (NATIVE_MOBILE_RESOURCES_REF) for reproducible builds.
run: curl -L -o project.zip "https://github.com/mendix/Native-Mobile-Resources/archive/${NATIVE_MOBILE_RESOURCES_REF}.zip"
- name: "Extract test project"
# GitHub names the archive's top dir after the ref (Native-Mobile-Resources-<ref>);
# rename it back to the stable Native-Mobile-Resources-main that the rest of the job
# references, so pinning the ref doesn't ripple through every downstream path.
run: |
unzip -qq project.zip -d .
rm -rf Native-Mobile-Resources-main
mv "Native-Mobile-Resources-${NATIVE_MOBILE_RESOURCES_REF}" Native-Mobile-Resources-main
- name: "Download resources artifact"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: resources
path: resources
- name: "List resources"
run: ls -R resources
- name: "Overlay built widget mpks"
if: ${{ github.event.inputs.workspace != 'js-actions' }}
shell: bash
run: |
if compgen -G 'resources/pluggableWidgets/**/dist/*/*.mpk' > /dev/null; then
for oldPath in resources/pluggableWidgets/**/dist/*/*.mpk; do
newPath="Native-Mobile-Resources-main/widgets/$(basename "$oldPath")"
mv -f "$oldPath" "$newPath"
done
fi
- name: "Register widgets in the test project"
# Run unconditionally: update-widgets must sync the project's widget definitions with the
# mpks in widgets/ regardless of whether THIS run overlaid fresh ones. Must NOT be nested
# in the mpk-move guard — a `js-actions` dispatch (or any run that builds no mpks) would
# then skip it and leave stale Atlas widget defs for the portable-app build.
shell: bash
run: mx update-widgets --loose-version-check Native-Mobile-Resources-main/NativeComponentsTestProject.mpr
- name: "Move mobile-resources"
shell: bash
run: |
if compgen -G 'resources/jsActions/mobile-resources-native/*' > /dev/null; then
mv -f resources/jsActions/mobile-resources-native/* Native-Mobile-Resources-main/javascriptsource/nativemobileresources/actions/
fi
- name: "Move nanoflow-actions"
shell: bash
run: |
if compgen -G 'resources/jsActions/mobile-resources-native/*' > /dev/null; then
mv -f resources/jsActions/nanoflow-actions-native/* Native-Mobile-Resources-main/javascriptsource/nanoflowcommons/actions/
fi
- name: "Build portable app package (self-contained runtime)"
# Run this BEFORE the native-packager deploy: portable-app-package re-prepares and
# CLEANS the project's deployment/ directory, which would wipe the native bundles.
# app.zip lives outside deployment/, so the later deploy's clean doesn't touch it.
# Output must live inside the project root: mxbuild's WhitelistAware path check
# rejects arbitrary output paths for the portable-app-package target.
run: |
mxbuild --target=portable-app-package \
-o Native-Mobile-Resources-main/app.zip \
--loose-version-check Native-Mobile-Resources-main/NativeComponentsTestProject.mpr
- name: "Build native bundles (deploy + native-packager)"
# Compiles the model and emits the Android/iOS JS bundles in native-template-ready
# layout at deployment/native/bundle/{android,iOS}. Supported replacement for the old
# create-native-bundle hack. Runs LAST so nothing cleans deployment/ afterward.
run: |
mxbuild --target=deploy --native-packager \
--loose-version-check Native-Mobile-Resources-main/NativeComponentsTestProject.mpr
echo "=== native bundle output ==="
ls -R Native-Mobile-Resources-main/deployment/native/bundle 2>/dev/null \
|| find Native-Mobile-Resources-main -name 'index.*.bundle' -o -name 'bundle' -type d 2>/dev/null
- name: "Upload Android bundle"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: android-bundle
path: Native-Mobile-Resources-main/deployment/native/bundle/android
if-no-files-found: error
- name: "Upload iOS bundle"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: ios-bundle
path: Native-Mobile-Resources-main/deployment/native/bundle/iOS
if-no-files-found: error
- name: "Upload portable app package"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: app-package
path: Native-Mobile-Resources-main/app.zip
android-app:
needs: [project, determine-nt-version]
runs-on: ubuntu-26.04
# Skip this job if we're using artifacts from a specific run; the native bundles now come
# from the project job (deploy + native-packager), so gate on project success.
if: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID == '' && always() && (needs.project.result == 'success') }}
steps:
- name: Debug branch value
run: echo "Using branch ${{ needs.determine-nt-version.outputs.nt_branch }}"
- name: "Check out Native Template for Native Components Test Project"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: mendix/native-template
ref: ${{ needs.determine-nt-version.outputs.nt_branch }}
path: native-template
- name: "Detect Android NDK version from native-template"
shell: bash
run: |
set -euo pipefail
# native-template declares e.g. ndkVersion = "27.3.13750724" in android/build.gradle
NDK=$(grep -oE 'ndkVersion[[:space:]]*=?[[:space:]]*"[0-9.]+"' native-template/android/build.gradle \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
if [ -z "$NDK" ]; then
echo "::warning::Could not detect ndkVersion from native-template; gradle will resolve/download it."
else
echo "Detected NDK version: $NDK"
fi
echo "ANDROID_NDK_VERSION=$NDK" >> "$GITHUB_ENV"
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: native-widgets
- name: "Download Android bundle and assets"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-bundle
path: bundles/android
- name: "Set up Node"
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: native-template/.nvmrc
cache: npm
cache-dependency-path: native-template/package-lock.json
- name: "Cache Android Build"
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: native-template/android/app/build
key: ${{ runner.os }}-android-build-${{ hashFiles('native-template/android/app/src/**/*.java', 'native-template/android/app/src/**/*.kt', 'native-template/android/app/build.gradle') }}
restore-keys: |
${{ runner.os }}-android-build-
- name: "Copy files to the right location"
run: |
# native-packager output is pre-split: bundle/android/{assets/index.android.bundle, res/...}
# maps directly onto app/src/main/{assets,res} (per the official manual-build doc).
mkdir -p native-template/android/app/src/main
cp -r bundles/android/* native-template/android/app/src/main/
mv native-widgets/configs/e2e/config.json native-template
mv native-widgets/configs/e2e/google-services.json native-template/android/app
node native-widgets/scripts/test/add-native-dependencies.js
- name: "Install dependencies"
working-directory: native-template
run: npm i
- name: "Setup JDK "
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
with:
java-version: 21
distribution: temurin
cache: gradle
# Install the NDK explicitly and cache it (keyed on the version detected from
# native-template), instead of letting the gradle build download ~1GB every run.
# (${{ }} expressions are expanded by Actions, so they are safe in cache `path`
# — unlike shell $VARs.) If detection failed, these no-op and gradle resolves it.
- name: "Cache Android NDK"
id: cache-ndk
if: env.ANDROID_NDK_VERSION != ''
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: /usr/local/lib/android/sdk/ndk/${{ env.ANDROID_NDK_VERSION }}
key: ${{ runner.os }}-android-ndk-${{ env.ANDROID_NDK_VERSION }}
- name: "Install Android NDK"
if: env.ANDROID_NDK_VERSION != '' && steps.cache-ndk.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMANAGER" ]; then
SDKMANAGER="$(command -v sdkmanager || true)"
fi
if [ -z "${SDKMANAGER:-}" ] || [ ! -x "$SDKMANAGER" ]; then
SDKMANAGER="$(find "$SDK" -type f -name sdkmanager 2>/dev/null | head -1)"
fi
echo "Using sdkmanager: $SDKMANAGER"
# Accept licenses first (yes can SIGPIPE under pipefail, so tolerate it), then install.
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
"$SDKMANAGER" "ndk;${ANDROID_NDK_VERSION}"
- name: "Build Android app"
working-directory: native-template/android
# The CI emulator is x86_64, so only compile that ABI's native code instead of all
# four (arm64-v8a, armeabi-v7a, x86, x86_64) — cuts the C/C++ build by ~75%.
#
# --build-cache turns on Gradle's local build cache (task-output content cache). It lives
# under ~/.gradle/caches/build-cache-1, already persisted by setup-java's `cache: gradle`,
# so unchanged tasks (incl. RN libs' native compiles, which don't change run-to-run) are
# reused instead of recompiled. Safe: a cache miss just rebuilds.
run: ./gradlew assembleAppstoreDebug assembleAppstoreDebugAndroidTest -PreactNativeArchitectures=x86_64 --build-cache
- name: "List APK files"
run: |
echo "Listing APK files in the output directory:"
ls -R native-template/android/app/build/outputs/apk/
- name: "Archive Android app"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: android-app
path: native-template/android/app/build/outputs/apk/**/*.apk
ios-app:
needs: [project, determine-nt-version]
runs-on: macos-26
# Skip this job if we're using artifacts from a specific run; the native bundles now come
# from the project job (deploy + native-packager), so gate on project success.
if: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID == '' && always() && (needs.project.result == 'success') }}
steps:
- name: "Check out Native Template for Native Components Test Project"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: mendix/native-template
ref: ${{ needs.determine-nt-version.outputs.nt_branch }}
path: native-template
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: native-widgets
- name: "Download iOS bundle and assets"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-bundle
path: bundles/ios
- name: "Set up Node"
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: native-template/.nvmrc
cache: npm
cache-dependency-path: native-template/package-lock.json
- name: "Cache iOS Build"
# Persists the DerivedData root (`build/`) so unchanged products can be reused across
# runs; restore-keys falls back to the latest entry on a partial-input change.
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: native-template/ios/build
key: ${{ runner.os }}-ios-build-${{ hashFiles('native-template/ios/**/*.swift', 'native-template/ios/**/*.h', 'native-template/ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-ios-build-
- name: "Copy files to the right location"
run: |
# native-packager emits bundle/iOS/{index.ios.bundle, assets/...}; copy the lot into
# ios/Bundle (per the official manual-build doc).
mkdir -p native-template/ios/Bundle
cp -r bundles/ios/* native-template/ios/Bundle/
mv native-widgets/configs/e2e/config.json native-template
node native-widgets/scripts/test/add-native-dependencies.js
- name: "Install Node dependencies"
working-directory: native-template
run: npm i
- name: "Setup Pods cache"
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
# Also cache CocoaPods' own download cache so `pod install` is fast even when
# the Pods/ dir misses. (~ is expanded by actions/cache.)
path: |
native-template/Pods
~/Library/Caches/CocoaPods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: "Install Pod dependencies"
working-directory: native-template/ios
run: pod install
- name: "Build iOS app"
working-directory: native-template/ios
# Build for the simulator's native arch (arm64 on Apple Silicon runners).
# ONLY_ACTIVE_ARCH=YES already limits to the active arch; the old
# VALID_ARCHS="i386 x86_64" forced dead/foreign archs and broke on macos-26.
run: xcodebuild -workspace NativeTemplate.xcworkspace -scheme nativeTemplate -configuration Debug -sdk iphonesimulator -derivedDataPath build ONLY_ACTIVE_ARCH=YES
- name: "Archive iOS app"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
with:
name: ios-app
path: native-template/ios/build/Build/Products/**/*.app
android-avd-cache:
runs-on: ubuntu-26.04
# Warm a booted-emulator snapshot once and cache it, so the per-widget Android shards
# restore it instead of cold-booting (~1-2 min saved per shard). Pure speed optimization:
# shards fall back to a cold boot on cache miss, so a failure here doesn't block tests.
steps:
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: "AVD cache"
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
# Keep in sync with the emulator config and the restore step in the test jobs.
key: avd-android-35-x86_64-google_apis-pixel
- name: "Create AVD and generate snapshot for caching"
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel
force-avd-creation: false
# 2 vCPUs for the emulator. GitHub-hosted Linux runners have only 4 vCPUs total, so
# leave 2 for the host (adb, Maestro, the JS bundler proxy). This is the action's
# default and the sweet spot on free runners; it's written to the AVD's config.ini
# (hw.cpu.ncore) and is what the emulator actually honors — a `-cores N` in
# emulator-options below would be silently overridden, so we set it here instead.
cores: 2
emulator-options: "-no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 4096"
disable-animations: false
script: echo "Generated AVD snapshot for caching."
android-widget-tests:
needs: [scope, mendix-version, project, android-app, android-avd-cache]
# Run if widgets need testing (widgets_to_test is not empty) and project succeeds
if: ${{ needs.scope.outputs.widgets_to_test != '[]' && always() && needs.project.result == 'success' && (needs.android-app.result == 'success' || needs.android-app.result == 'skipped') }}
runs-on: ubuntu-26.04
# 30 min hard cap per widget shard. A healthy widget's flows run in ~30s each; the cap is a
# backstop for genuinely wedged shards. Bumped 20→30 after a run where shards were cancelled
# at 20 min — that was traced to a video-recording bug (the on-device screenrecord wasn't
# being stopped, adding ~2.5 min/flow); with that fixed 30 is comfortable headroom. If shards
# still hit 30, the cause is real app instability, not the cap — needs a different fix.
timeout-minutes: 30
strategy:
# 5 parallel Linux shards. Bounded by GitHub Free's 20 total concurrent-job cap (not the
# macOS-5 cap that limits the iOS matrix), so there's headroom. iOS stays at 4 to keep a
# macOS slot of headroom under that 5-job ceiling.
max-parallel: 5
matrix:
widget: ${{ fromJson(needs.scope.outputs.widgets_to_test) }}
fail-fast: false
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: "Download portable app package"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: app-package
# Used only when specific run id is provided when triggering the jbo
- name: "Download Android app from specific run"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID != '' }}
uses: ./.github/actions/use-arficats-from-specific-run
with:
artifact_name: android-app
run_id: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID }}
output_dir: android-app
platform: android
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: "Download Android app"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID == '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-app
path: android-app
- name: "Setup needed tools"
id: setup-needed-tools
uses: ./.github/actions/setup-tools
- name: "Start Mendix runtime"
uses: ./.github/actions/start-mendix-runtime
with:
java-home: ${{ steps.setup-needed-tools.outputs.java-path }}
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: "Restore AVD snapshot"
# Restore-only: the android-avd-cache job is the sole writer, so shards don't
# each try to re-save the same key. Must match that job's key.
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-android-35-x86_64-google_apis-pixel
- name: "Run android tests"
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel
# Boot from the cached snapshot (don't overwrite it); falls back to a cold boot on cache miss.
force-avd-creation: false
# 2 vCPUs: free GitHub runners have 4 total; leave 2 for the host. Set via the action
# input (written to config.ini hw.cpu.ncore) — a `-cores N` emulator-option is overridden.
cores: 2
emulator-options: "-no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save -memory 4096"
disable-animations: true
script: |
chmod +x maestro/helpers/prepare_android.sh
chmod +x maestro/run_maestro_widget_tests.sh
bash maestro/helpers/prepare_android.sh
bash maestro/run_maestro_widget_tests.sh android "${{ matrix.widget }}" || [ "$UPDATE_BASELINES" = "true" ]
- name: Archive test results
# always(): the test step exits non-zero on a failing shard, which would otherwise
# SKIP this step — i.e. we'd capture screenshots/logs/videos for green shards but NOT
# for the failing ones we actually need to debug. Run regardless of test outcome.
if: ${{ always() }}
uses: ./.github/actions/archive-test-results
with:
platform: android
test-type: ${{ matrix.widget }}
ios-widget-tests:
needs: [scope, mendix-version, project, ios-app]
# Run if widgets need testing (widgets_to_test is not empty) and project succeeds
if: ${{ needs.scope.outputs.widgets_to_test != '[]' && always() && needs.project.result == 'success' && (needs.ios-app.result == 'success' || needs.ios-app.result == 'skipped') }}
runs-on: macos-26
# 30 min per widget shard, matching android-widget-tests. iOS needs the budget more than
# Android: sim cold-boot + Maestro driver startup + runtime-ready wait eat several minutes
# before any flow runs, so on a free macOS runner slow-but-healthy shards were getting
# truncated — at 20 min several shards (accordion, background-image, bottom-sheet,
# color-picker, safe-area-view) were cancelled mid-flow. If a shard still hits 30 the cause
# is real app instability, not the cap.
timeout-minutes: 30
strategy:
max-parallel: 4
matrix:
widget: ${{ fromJson(needs.scope.outputs.widgets_to_test) }}
fail-fast: false
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
fetch-tags: false
clean: true
ref: ${{ github.sha }}
- name: "Download portable app package"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: app-package
# Used only for local testing, will be empty on GitHub
- name: "Download iOS app from specific run"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID != '' }}
uses: ./.github/actions/use-arficats-from-specific-run
with:
artifact_name: ios-app
run_id: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID }}
output_dir: ios-app
platform: ios
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: "Download iOS app"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID == '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-app
path: ios-app
- name: "Setup needed tools"
id: setup-needed-tools
uses: ./.github/actions/setup-tools
- name: "Start Mendix runtime"
uses: ./.github/actions/start-mendix-runtime
with:
java-home: ${{ steps.setup-needed-tools.outputs.java-path }}
- name: "Setup Xcode CLI Tools"
uses: ./.github/actions/setup-xcode-cli-tools
- name: "Run iOS tests"
run: |
chmod +x maestro/helpers/prepare_ios.sh
chmod +x maestro/run_maestro_widget_tests.sh
bash maestro/helpers/prepare_ios.sh
bash maestro/run_maestro_widget_tests.sh ios "${{ matrix.widget }}" || [ "$UPDATE_BASELINES" = "true" ]
- name: Archive test results
# always(): capture failure artifacts (the failing shard's test step exits non-zero).
if: ${{ always() }}
uses: ./.github/actions/archive-test-results
with:
platform: ios
test-type: ${{ matrix.widget }}
android-js-tests:
needs: [scope, mendix-version, project, android-app, android-avd-cache]
# Run if JS actions changed and project succeeds and either android-app succeeds OR we're using custom artifacts (android-app was skipped)
if: ${{ needs.scope.outputs.js_actions_changed == 'true' && always() && needs.project.result == 'success' && (needs.android-app.result == 'success' || needs.android-app.result == 'skipped') }}
runs-on: ubuntu-26.04
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: "Download portable app package"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: app-package
# Used only for local testing, will be empty on GitHub
- name: "Download Android app from specific run"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID != '' }}
uses: ./.github/actions/use-arficats-from-specific-run
with:
artifact_name: android-app
run_id: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID }}
output_dir: android-app
platform: android
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: "Download Android app"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID == '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-app
path: android-app
- name: "Setup needed tools"
id: setup-needed-tools
uses: ./.github/actions/setup-tools
- name: "Start Mendix runtime"
uses: ./.github/actions/start-mendix-runtime
with:
java-home: ${{ steps.setup-needed-tools.outputs.java-path }}
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: "Restore AVD snapshot"
# Restore-only: the android-avd-cache job is the sole writer, so shards don't
# each try to re-save the same key. Must match that job's key.
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-android-35-x86_64-google_apis-pixel
- name: "Run android tests"
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel
# Boot from the cached snapshot (don't overwrite it); falls back to a cold boot on cache miss.
force-avd-creation: false
# 2 vCPUs: free GitHub runners have 4 total; leave 2 for the host. Set via the action
# input (written to config.ini hw.cpu.ncore) — a `-cores N` emulator-option is overridden.
cores: 2
emulator-options: "-no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save -memory 4096"
disable-animations: true
script: |
chmod +x maestro/helpers/prepare_android.sh
chmod +x maestro/run_maestro_jsactions_tests.sh
bash maestro/helpers/prepare_android.sh
bash maestro/run_maestro_jsactions_tests.sh android || [ "$UPDATE_BASELINES" = "true" ]
- name: Archive test results
# always(): capture failure artifacts (the failing shard's test step exits non-zero).
if: ${{ always() }}
uses: ./.github/actions/archive-test-results
with:
platform: android
test-type: js-actions
ios-js-tests:
needs: [scope, mendix-version, project, ios-app]
# Run if JS actions changed and project succeeds and either ios-app succeeds OR we're using custom artifacts (ios-app was skipped)
if: ${{ needs.scope.outputs.js_actions_changed == 'true' && always() && needs.project.result == 'success' && (needs.ios-app.result == 'success' || needs.ios-app.result == 'skipped') }}
runs-on: macos-26
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
fetch-tags: false
clean: true
- name: "Download portable app package"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: app-package
# Used only for local testing, will be empty on GitHub
- name: "Download iOS app from specific run"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID != '' }}
uses: ./.github/actions/use-arficats-from-specific-run
with:
artifact_name: ios-app
run_id: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID }}
output_dir: ios-app
platform: ios
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: "Download iOS app"
if: ${{ env.LOCAL_TEST_ARTIFACT_RUN_ID == '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-app
path: ios-app
- name: "Setup needed tools"
id: setup-needed-tools
uses: ./.github/actions/setup-tools
- name: "Start Mendix runtime"
uses: ./.github/actions/start-mendix-runtime
with:
java-home: ${{ steps.setup-needed-tools.outputs.java-path }}
- name: "Setup Xcode CLI Tools"
uses: ./.github/actions/setup-xcode-cli-tools
- name: "Run iOS tests"
run: |
chmod +x maestro/helpers/prepare_ios.sh
chmod +x maestro/run_maestro_jsactions_tests.sh
bash maestro/helpers/prepare_ios.sh
bash maestro/run_maestro_jsactions_tests.sh ios || [ "$UPDATE_BASELINES" = "true" ]
- name: Archive test results
# always(): capture failure artifacts (the failing shard's test step exits non-zero).
if: ${{ always() }}
uses: ./.github/actions/archive-test-results
with:
platform: ios
test-type: js-actions
# Each widget/JS shard uploads its own per-shard screenshots/logs/artifacts (one set of three
# per matrix entry → dozens of tiny artifacts). This job merges them into a handful of combined
# zips so a reviewer can grab everything in one download. `delete-merged` removes the per-shard
# sources after a successful merge, leaving just the combined artifacts.
aggregate-test-results:
needs: [android-widget-tests, ios-widget-tests, android-js-tests, ios-js-tests]
# Run even when shards fail/cancel — the failing shards are exactly the ones whose
# screenshots/logs we want to inspect.
if: ${{ always() }}
runs-on: ubuntu-26.04
# The workflow-level block grants only packages:write, so every other scope defaults to
# none. This job needs contents:read (to check out the triage script) and actions:read
# (the script reads each shard's conclusion and logs via the Actions API).
permissions:
contents: read
actions: read
steps:
- name: "Check out code"
# Needed only for the triage script below; the merge steps don't touch the repo.
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: "Write test result summary"
# Triage table for every widget/JS shard, rendered on the run's Summary tab. Beyond
# pass/fail it classifies each red shard (timeout / emulator / runtime / artifact /
# screenshot mismatch / …) so a reviewer can tell a real failure from an infra flake
# without opening logs. Each shard's result lives in its own matrix job; the script
# reads them back from the API (this job `needs:` them all, so conclusions are final).
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bash .github/scripts/triage-test-results.sh \
"${{ github.repository }}" "${{ github.run_id }}" >> "$GITHUB_STEP_SUMMARY"
- name: "Merge screenshots"
uses: actions/upload-artifact/merge@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
# continue-on-error: a run that produced no screenshots (e.g. only JS-action tests) has
# nothing to merge and the action would otherwise hard-fail. Same for the two below.
continue-on-error: true
with:
name: all-screenshots
pattern: "*-screenshots-*"
# Keep each shard's PNGs in its own subdir so identically-named actual/ files don't collide.
separate-directories: true
delete-merged: true
- name: "Merge screenshot diffs"
uses: actions/upload-artifact/merge@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
# Only mismatching flows produce a diff; a run with no screenshot failures has none.
continue-on-error: true
with:
name: all-screenshot-diffs
pattern: "*-diffs-*"
separate-directories: true
delete-merged: true
- name: "Merge runtime logs"
uses: actions/upload-artifact/merge@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
continue-on-error: true
with:
name: all-runtime-logs
pattern: "*-runtime-logs-*"
separate-directories: true
delete-merged: true
- name: "Merge debug artifacts"
uses: actions/upload-artifact/merge@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
continue-on-error: true
with:
name: all-debug-artifacts
pattern: "*-artifacts-*"
separate-directories: true
delete-merged: true
- name: "Merge failure videos"
uses: actions/upload-artifact/merge@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
# Only failing flows leave a video behind; a fully-green run has none to merge.
continue-on-error: true
with:
name: all-failure-videos
pattern: "*-videos-*"
separate-directories: true
delete-merged: true