Skip to content

E2e pipeline overhaul#529

Merged
vadymv-mendix merged 43 commits into
mendix:mainfrom
vadim-v:e2e-pipeline-overhaul
Jun 26, 2026
Merged

E2e pipeline overhaul#529
vadymv-mendix merged 43 commits into
mendix:mainfrom
vadim-v:e2e-pipeline-overhaul

Conversation

@vadim-v

@vadim-v vadim-v commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

A structural rework of .github/workflows/NativePipeline.yml and its Maestro test
harness. The goal is to make the native end-to-end suite fast, reproducible, and
honest
— today it is slow and fails the majority of runs, mostly for infrastructure
reasons that mask the handful of real widget problems underneath.

This document walks reviewers through the most impactful changes, in rough order of
impact, with representative diffs. Per-flow failure-video recording (§9) earns its
keep: it's what turned an opaque "couldn't find element" failure into a visible diagnosis
of the trial-license session cap described in § What this surfaced.
It is no longer treated as optional.

Scope: 30+ commits, ~170 files. Most of the file count is one-line
assertScreenshot additions across 52 Maestro flows (see §3); the structural
surface area is small and concentrated in the workflow + a few composite actions.


TL;DR for reviewers

# Change Why it matters
1 Self-contained runtime via Portable App Distribution (PAD) Deletes a fragile hand-rolled runtime (m2ee-tools clone + CDN download + fake health check). One supported artifact, real readiness check.
2 Native bundling via mxbuild --native-packager Collapses two duplicate bundle jobs into the build stage using the supported target.
3 Visual regression moved into Maestro (assertScreenshot) Deletes a custom pixel-diff script + a whole decoupled job; each widget shard now owns its own visual result.
4 Fast-fail test policy Kills retry/timeout amplification (a broken shard went from ~25–60 min to ~1–5 min) and adds an up-front smoke check.
5 Caching & snapshots PAD package, pre-booted AVD snapshot, content-hash dist cache, Gradle/Pods/NDK caches.
6 Reproducibility & robustness iOS device pin-with-fallback, bounded Android boot poll, pinned actions, concurrency.
7 Observability Combined artifacts + a $GITHUB_STEP_SUMMARY pass/fail table.
8 Tooling cleanup Drop the Python toolchain (Node-only scripting); fail loudly instead of silently pinning master.
9 Per-flow failure videos BrowserStack-style: record every flow, keep only failures. Already paid for itself — the video is what diagnosed the session-cap wedge (§ What this surfaced).

Please also read § What this surfaced and
§ Known limitations before reviewing
— they explain why some
runs are still red and what is deliberately left for follow-up.


1. Self-contained runtime via Portable App Distribution (PAD)

Before: the test job assembled a runtime by hand — cloning m2ee-tools,
downloading the runtime from a CDN, and using a config file + a health check that
didn't actually confirm the runtime was serving. Fragile and version-drift-prone.

After: the build stage produces a self-contained portable app package
(mxbuild --target=portable-app-packageapp.zip, which bundles the Java runtime
and a bin/start). The test job just extracts it, starts it, and polls until it
truly serves HTTP 200
before any test runs.

 .github/scripts/setup-runtime.sh                  | deleted
 .github/scripts/start-runtime-with-verification.sh | deleted
 configs/e2e/m2ee-native.yml                        | deleted
+.github/actions/start-mendix-runtime/action.yaml   | added

New runtime action (the real readiness gate, abridged):

# .github/actions/start-mendix-runtime/action.yaml
unzip -qq "$APP_PACKAGE" -d runtime-app
chmod +x runtime-app/bin/start
(cd runtime-app && nohup ./bin/start etc/Default > "$GITHUB_WORKSPACE/log/runtime.log" 2>&1 &)

deadline=$(( SECONDS + TIMEOUT_SECONDS ))     # default 300s
until curl -fsS -o /dev/null "$RUNTIME_URL"; do
  if [ "$SECONDS" -ge "$deadline" ]; then
    echo "::error::Mendix runtime did not become ready within ${TIMEOUT_SECONDS}s"
    tail -n 100 "$GITHUB_WORKSPACE/log/runtime.log"
    exit 1
  fi
  sleep 3
done

Built once in the project job and shared as the app-package artifact:

+      - name: "Build portable app package (self-contained runtime)"
+        run: |
+          mxbuild --target=portable-app-package \
+            -o Native-Mobile-Resources-main/app.zip \
+            --loose-version-check Native-Mobile-Resources-main/NativeComponentsTestProject.mpr

Validated locally in Docker: produces a ~451 MB self-contained zip, boots and serves
HTTP 200 on :8080 in ~18s with JAVA_HOME + M2EE_ADMIN_PASS + ./bin/start etc/Default.


2. Native bundling via mxbuild --native-packager

PAD ships the server runtime, not the RN JS bundle. Previously two near-identical
android-bundle / ios-bundle jobs (plus a create-native-bundle composite) produced
those bundles via an internal-tools hack. This replaces them with the supported
mxbuild --target=deploy --native-packager target, run in the build stage.

 .github/actions/create-native-bundle/action.yml | deleted
+      - name: "Build native bundles (deploy + native-packager)"
+        run: |
+          mxbuild --target=deploy --native-packager \
+            --loose-version-check Native-Mobile-Resources-main/NativeComponentsTestProject.mpr

Ordering gotcha (worth a reviewer's eye): portable-app-package re-prepares and
cleans deployment/, which would wipe freshly-built native bundles. So the
portable app is built first (its app.zip lives outside deployment/), and the
native-packager deploy runs last. Bundle uploads use if-no-files-found: error
so a missing path fails the build loudly instead of silently shipping nothing.


3. Visual regression moved into Maestro (assertScreenshot)

Before: flows only did takeScreenshot; a separate compare-screenshots job later
diffed PNGs with a custom pixelmatch script (maestro/helpers/compare_screenshots.js).
A decoupled gate with gh run download || true gaps and three extra npm deps.

After: each flow asserts visually in-line with Maestro's built-in
assertScreenshot (default 95% threshold). The widget's own shard owns its visual
result — no separate job, no extra deps.

 - takeScreenshot:
     path: "maestro/images/actual/${PLATFORM}/animation"
+- assertScreenshot:
+    path: "maestro/images/expected/${PLATFORM}/animation.png"
 maestro/helpers/compare_screenshots.js | deleted
 package.json: -pixelmatch -pngjs -@actions/core   (3 devDeps removed; lockfile refreshed)

This one-line addition is repeated across 52 flows — that's the bulk of the file count.


4. Fast-fail test policy (the biggest runtime/stability lever)

The old harness amplified every failure: up to 4 attempts per flow (initial + 3
retries) × a 5-minute driver-startup timeout × 240s × 3 precondition waits, all
under a 60-minute job cap. A broken build could grind for ~25–60 minutes before failing.

Changes:

  • MAX_RETRIES 3 → 1, driver-startup timeout 300s → 120s.
  • Precondition waits trimmed (240s×3 → 60s×2).
  • Job timeout-minutes 60 → 20 (iOS) / 30 (Android) — backstop only.
  • New up-front smoke check (Smoke.yaml): launch the app and confirm the Widgets
    menu renders once before running any flow. A fundamentally broken build now fails
    in ~1 min instead of grinding every flow.
-MAX_RETRIES=3
+MAX_RETRIES=1
-MAESTRO_DRIVER_STARTUP_TIMEOUT=300000
+MAESTRO_DRIVER_STARTUP_TIMEOUT=120000
+# Fast-fail smoke check: verify the app launches and the Widgets menu renders ONCE,
+# before running any widget flows. Retries ONCE (resetting the sim/emulator) to absorb
+# the transient Maestro driver-attach flake, then aborts the shard if still broken.
+if ! smoke_check; then
+  exit 1
+fi

Net effect: worst-case broken shard ~25 min → ~1–5 min, and healthy shards finish well
under the cap.


5. Caching & snapshots

Several independent caches, each keyed on the inputs that actually invalidate it:

  • Portable app package — built once, consumed by every test shard (§1).
  • Pre-booted Android AVD snapshot — a dedicated android-avd-cache job is the sole
    writer
    ; test shards restore read-only (no thundering-herd re-saves).
  • Content-hash dist cache — on a full build (full_build=true: infra-only
    PRs, JS-action-only changes, or a full dispatch) an exact source-hash hit lets the job
    skip the rebuild entirely. On a partial (widget) PR the job does a fast scoped build
    of just the changed widgets via pnpm --filter instead.
  • Build scope excludes e2e/ changes — the test scope and the build scope are now
    separate. A change confined to a widget's e2e/ folder (Maestro flows / screenshots)
    changes only the test, not the built artifact, so it no longer rebuilds the
    widget
    — the widget is still added to the test matrix and runs against the test
    project's baseline .mpk (exactly like every other not-rebuilt widget in a partial
    run). Concretely, this very PR (mostly e2e/*.yaml + infra) drops from "rebuild all 20
    widgets" to "rebuild 0". A safety-net manifest keeps the resources artifact non-empty
    when a run builds nothing, so the project download never fails.
  • Gradle / CocoaPods / NDK caches with restore-keys fallback.
# android-avd-cache job — single writer; shards restore-only against this exact key
key: avd-android-35-x86_64-google_apis-pixel
# resources: rebuild only when widget/JS-action sources or deps actually change
key: resources-dist-v1-${{ hashFiles(
  'packages/pluggableWidgets/*/src/**', 'packages/jsActions/*/src/**',
  'packages/pluggableWidgets/*/package.json', 'packages/jsActions/*/package.json',
  'pnpm-lock.yaml') }}

6. Reproducibility & robustness

  • iOS device pin-with-fallback: prepare_ios.sh prefers a pinned device
    (PREFERRED_IOS_DEVICE, default iPhone 17 Pro — what macos-26 ships and baselines
    are captured on) for reproducible screenshots; falls back to the newest available
    device with a loud ::warning:: if absent. Avoids both the old hardcoded hard-fail
    and silent device drift.
  • Bounded Android boot poll: replaced a blind sleep 30 with a state-based wait
    (adb wait-for-devicesys.boot_completed → package manager ready, 300s cap).
  • Pinned test project: mendix/Native-Mobile-Resources is downloaded at a pinned
    commit SHA (NATIVE_MOBILE_RESOURCES_REF, overridable via the test_project_ref
    dispatch input) instead of a moving main, so a build is reproducible.
  • Robust PR scoping: the scope job checks out full history and diffs against the
    merge-base with the PR base branch, so every commit in a multi-commit PR is
    considered (it previously diffed only HEAD~1 via an empty github.event.before,
    silently under-scoping). No third-party action added — done in the existing script.
  • Pinned third-party actions (by SHA), a single runner image family, and a
    concurrency block to cancel superseded runs.

7. Observability

  • Combined artifacts: an aggregate-test-results job merges the dozens of per-shard
    outputs into a handful — all-screenshots, all-runtime-logs, all-debug-artifacts
    — so reviewers download once instead of per-shard.
  • $GITHUB_STEP_SUMMARY: the same job writes a consolidated per-shard pass/fail
    table (read back via gh api --paginate over the run's jobs) to the run Summary tab.
  • Failure artifacts actually upload now: the Archive test results steps were
    missing if: always(), so a failing shard (run script exits non-zero) skipped
    archiving — meaning screenshots/logs were captured only for green shards. Fixed.

8. Tooling cleanup — drop Python, fail loudly

  • Ported determine-nt-version.pydetermine-nt-version.mjs (global fetch + a tiny
    version comparator, no deps, runs on the runner's preinstalled Node). Deleted the
    dead setup-python action and inline pip install. Python is now fully gone.
  • The version resolver previously fell back to a silent nt_branch=master (an unpinned
    moving target) on no-match or a transient API blip. It now exits 1 loudly instead.
-          pip install requests packaging
-          python ./.github/scripts/determine-nt-version.py "$VERSION"
+          node ./.github/scripts/determine-nt-version.mjs "$VERSION"

Deliberate scope call: the remaining shell glue (determine-widget-scope.sh,
maestro/helpers/*.sh) is kept as-is. The standardization win was eliminating the
third toolchain (Python), not churning battle-tested bash into JS for its own sake.


9. Per-flow failure videos

BrowserStack-style debugging: record a screen video around every flow, keep it
only if the flow fails
, delete it on pass. Each kept clip is a short, targeted
recording of exactly the failing interaction — far more useful than a "couldn't find
element" log line. Toggle off with RECORD_VIDEO=false.

This feature already paid for itself. The trial-license session cap in
§ What this surfaced was invisible in the Maestro logs (just
"couldn't find Widgets menu"); the failure video showed the app sitting on the
"maximum number of users" screen, which is what led straight to the runtime-log
confirmation and the fix. It earns its place in the suite.

  • iOS: xcrun simctl io booted recordVideo (SIGINT-finalized, no length cap).
  • Android: adb shell screenrecord --time-limit 180 (3-min/segment cap). The recorder is
    stopped with adb shell pkill -INT screenrecord (stopping only the local adb client
    leaves the on-device recorder running to its time-limit — which earlier added
    ~2.5 min/flow and blew the shard timeout).
+start_recording "$(basename "${yaml_test_file%.yaml}")"
 if maestro test ... "$yaml_test_file"; then
   echo "✅ Test passed"
+  stop_recording discard          # delete the clip on pass
 else
   echo "❌ Test failed"
+  stop_recording keep             # retain the clip for debugging
 fi

Merged into a single all-failure-videos artifact alongside the others. It's
self-contained (one toggle, no coupling to the rest of the PR) — but on the strength of
the session-cap diagnosis it's a keep, not a cut.


What this surfaced (please read)

Tightening retries/timeouts and adding the smoke check did not introduce new
failures — it un-masked an existing one, and the root cause turned out to be the
Mendix trial license, not the widgets.

The runtime runs under a trial license capped at 6 concurrent named users. Every
Maestro flow does launchApp clearState: true, which clears device state but starts a
fresh server-side session that lingers for the default 10-minute SessionTimeout.
A widget's flows therefore accumulate one session each. The residual failures cluster on
exactly the widgets whose flow count pushes a shard past 6 live sessions (smoke launch +
flows + retries):

Shard Flows (+smoke) Result
background-image-native 8 (+1) over cap → wedge
safe-area-view-native 7 (+1) over cap → wedge
accordion-native 6 (+1) over cap → wedge
js-actions (mobile-resources 5 + nanoflow 3) 8 over cap → wedge
every other widget 1–5 under cap → green

The runtime log is unambiguous: after 6 sessions it logs Maximum number of sessions exceeded! (You are currently using a trial license), the next launchApp lands on the
user-limit screen, Precondition can't find the "Widgets" menu, and the shard
"wedges" mid-suite. That is the same symptom previously mis-attributed to image-rendering
OOM / per-widget instability — it isn't widget-specific at all.

Why fast-fail exposed it: the old config's 4× retries and long timeouts stretched a
shard's flows past the 10-minute session window, so early sessions expired and freed
license slots before later retries ran — eventual green, just slow and noisy. Fast-fail
concentrates all 6–9 launches inside the 10-minute window, so every session is alive at
once and the cap trips. The speed-up didn't add instability; it removed the accidental
delay that was hiding a session-cap.

Fix: the start-mendix-runtime action now shortens the server session idle timeout
(RUNTIME_PARAMS_SESSIONTIMEOUT=60000) and reaps expired sessions promptly
(RUNTIME_PARAMS_CLUSTERMANAGERACTIONINTERVAL=30000) via the portable app's HOCON env
overrides. At ~30s/flow, each flow's session expires before the next ones pile up, holding
peak concurrency to ~2–3 — comfortably under 6 — without slowing the suite or touching any
test. (Identified from run 28168923730's safe-area-view runtime log; pending a clean
validation run.)


Known limitations / deliberately deferred

  • Two screenshot baselines are stale. background-image-native baselines could
    not be re-captured on earlier runs because its 8-flow shard tripped the trial-license
    session cap (see § What this surfaced) and wedged mid-suite — the
    capture run never produced clean screenshots. With the session-timeout fix these should
    now be re-capturable; re-baselining is pending a clean validation run.
  • No mid-suite fast-abort yet — the smoke check guards shard start only. With the
    session-cap fixed this is far less pressing, but a bail-after-N-consecutive-precondition-
    failures backstop is still worth adding for genuinely wedged apps.
  • Android video segments cap at 180s (screenrecord limit) — long flows truncate;
    still useful, but not unbounded like iOS.
  • e2e-only changes test against the baseline widget, not a fresh build. Because an
    e2e/-only change no longer rebuilds its widget (see §5), that widget's e2e runs
    against the test project's committed baseline .mpk. This matches how partial builds
    already treat every non-rebuilt widget, and is correct when the repo's widget source
    equals the released baseline; a widget with unreleased source changes on main and a
    test-only PR would be tested against the older released widget. (The same e2e/
    exclusion is not applied to JS-action packages — their js_actions_changed flag
    drives both build and test, and they're only two cheap packages.)
  • Open backlog (not in this PR): de-duplicating the
    4 near-identical test jobs into a reusable workflow; per-PR-branch version overrides;
    more aggressive Gradle native-build caching (.cxx/externalNativeBuild).

Validation status

  • PAD + native bundling: validated locally in Docker and on CI capture runs.
  • assertScreenshot, fast-fail, caching, combined artifacts, step summary, Node port:
    exercised on dispatch/PR runs on the fork.
  • 52 android / 51 iOS baselines captured on the pinned devices; 2 stale as noted above.

vadim-v added 30 commits June 23, 2026 22:41
…o+pnpm caching

- Add a concurrency group so a new push cancels superseded PR/branch runs
- Standardize all Linux jobs on ubuntu-26.04 and macOS jobs on macos-26
- Route the resources job through setup-node-with-cache (adds the pnpm store
  cache it was missing)
- Pin Maestro to cli-2.6.1 (supports assertScreenshot) and fix the cache
  path ($HOME -> ~, which actions/cache never expanded) so it actually hits
Replace the hand-rolled Mendix runtime setup on the test jobs with Mendix
Portable App Distribution (validated against the test project on 11.11.0).

- project job: also build `mxbuild --target=portable-app-package` and upload
  it as the `app-package` artifact (output kept inside the project dir to
  satisfy mxbuild's path whitelist). MDA still produced for the bundle jobs.
- new start-mendix-runtime composite: extract the package, start `bin/start`
  in the background, and poll a real readiness probe (HTTP) with a bounded
  timeout, replacing the fake `$?` health check and the nick-fields/retry wrapper.
- all 4 test jobs now consume app-package + the composite instead of the MDA.
- delete setup-runtime.sh, start-runtime-with-verification.sh and
  configs/e2e/m2ee-native.yml; drop the runtime-tarball and m2ee-tools caches
  from setup-tools (the package bundles the runtime, no CDN download / m2ee clone).

Needs a CI shakeout run to confirm the backgrounded runtime survives across
steps and the emulator/simulator reaches it.
…R namespace

Use ghcr.io/${{ github.repository }}/mxbuild instead of the hardcoded
ghcr.io/mendix/native-widgets/mxbuild. Identical to the previous value on
upstream, but lets a fork build/push/pull the image in its own namespace so
the pipeline is self-sufficient when run from a fork.
The Android app build let gradle download/install the NDK (~1GB) on every run.
Install it explicitly via sdkmanager in a dedicated step and cache it by version,
so subsequent runs restore from cache instead of re-downloading from Google.

The NDK version is a job-level env var that must track native-template/React Native.
Read ndkVersion from the checked-out native-template/android/build.gradle instead
of hardcoding it, so the cached/installed NDK always matches the resolved
native-template ref (different branches/tags pin different NDK versions). If
detection fails, the cache/install steps no-op and gradle resolves the NDK as before.
…r ABI only

The CI emulator is x86_64, but the gradle build compiled native C/C++ for all four
ABIs (arm64-v8a, armeabi-v7a, x86, x86_64). Pass -PreactNativeArchitectures=x86_64
to build just the one we run on, cutting the native build by ~75%.
setup-tools installed Python only for the old m2ee-based runtime, which the
portable app package replaced (it starts via bin/start on the JVM). Remove it
to trim test-job setup time.
… device/version

prepare_ios.sh hardcoded "iPhone 16" / iOS 18.5, which don't exist on macos-26
(every macOS image bump broke it). Parse `simctl list -j` and pick the available
iPhone with the highest (iOS runtime, model) — e.g. iPhone 17 / iOS 26 today.
restart_simulator now shuts down whatever is booted rather than a hardcoded name,
and the now-unused IOS_DEVICE vars are removed.
Add an android-avd-cache job that boots the emulator once and caches the AVD,
then have the per-widget Android test shards restore it and boot from snapshot
(force-avd-creation: false, -no-snapshot-save) instead of cold-booting every
shard (~1-2 min each). Keeps the matrix parallelism; the APK is still a quick
per-shard install. On cache miss shards fall back to a cold boot, so a failure
in the cache job doesn't block tests.
…t licenses safely

sdkmanager is not on PATH on the runner (it lives under the SDK cmdline-tools),
so the install step failed with "command not found". Resolve it from
ANDROID_HOME/cmdline-tools, with fallbacks. Also accept licenses in a separate
step tolerant of `yes` SIGPIPE under pipefail, then install without piping `yes`.
…oaPods downloads

The iOS build forced VALID_ARCHS="i386 x86_64", which is wrong on the arm64
macos-26 runners (i386 is dead, x86_64 mismatches the arm64 simulator) and made
xcodebuild compile foreign archs. Drop it and rely on ONLY_ACTIVE_ARCH=YES to
build the simulator's native arch (arm64), matching the arm64 iPhone 17 sim.

Also cache ~/Library/Caches/CocoaPods so `pod install` is fast even when the
Pods/ cache misses.
…est jobs

The iOS widget/js test jobs began with "Force cleanup workspace" / "Clean up any
Git locks" steps that sudo-pkill git/node/xcodebuild and `rm -rf .git` before
checkout. That's cargo-cult from self-hosted runners; GitHub-hosted macOS runners
are ephemeral and start clean, so these steps are pointless and risky. Remove them.
…estro analytics

- Android test shards now use actions/cache/restore for the AVD snapshot; the
  android-avd-cache job is the sole writer, so shards no longer each attempt to
  re-save the same key.
- Set MAESTRO_CLI_NO_ANALYTICS to skip Maestro's anonymous analytics network call.
…udor/action-zip

- Lower the Android/iOS widget-test matrices from max-parallel 5 to 4 to ease runner load.
- Replace the third-party montudor/action-zip Docker action with native `unzip`
  (apt-installed alongside curl) in the project job — removes a supply-chain
  dependency. Also drop the blanket `apt upgrade -y` (slow/nondeterministic; we
  only need curl + unzip).
…PTY flake)

The maestro test jobs ran `pnpm install` via setup-node-with-cache, which triggers
the root `prepare` lifecycle (building tool packages like piw-utils-internal). That
flaked with "ENOTEMPTY: rmdir dist/utils" and failed shards before any test ran —
and it was never needed: maestro is a standalone binary, so the test jobs need only
Java + Maestro. Removing it fixes the flake class and speeds up every test shard.
…meout

Broken widget tests took ~25 min because each failing test ran up to 4
times (1 initial in run_tests + 3 in the rerun loop), and each attempt
could stall on a 5-min MAESTRO_DRIVER_STARTUP_TIMEOUT before maestro gave
up (S6: retry amplification).

- MAX_RETRIES 3 -> 1 (2 attempts total, standard flake absorption)
- driver startup timeout 300000 -> 120000ms (maestro default is 15s)
- hoist the timeout to a single var instead of duplicating the literal

Worst-case broken-build time ~25 min -> ~5 min; healthy tests unchanged.
…Phase 4)

Visual regression now runs inside each Maestro flow via the built-in
assertScreenshot, so each widget's own shard owns its visual result —
no decoupled compare-screenshots gate and no "gh run download || true"
gaps that could silently skip a widget.

- 52 flows: add assertScreenshot (default 95% threshold, full screen)
  right after each takeScreenshot, comparing against the committed
  maestro/images/expected/<platform>/<name>.png baseline.
- Delete the compare-screenshots job, maestro/helpers/compare_screenshots.js,
  and the pixelmatch/pngjs/@actions/core devDeps.
- New update_baselines dispatch input -> UPDATE_BASELINES env; the 4 run
  steps append '|| [ "$UPDATE_BASELINES" = true ]' so a capture run can
  produce fresh baselines without going red. archive-test-results already
  uploads *-screenshots-* (if: always()) as the re-baseline source.

Maestro assertScreenshot has no region masking (only cropOn + a whole-screen
threshold); the old 2% pixel-diff masked the status bar / home indicator.
The Android status bar is already normalized in set_status_bar, and the
default 5% tolerance covers those small regions.

Follow-ups (not in this commit): capture+commit fresh baselines for both
platforms via update_baselines=true (iOS mandatory after the device change);
refresh pnpm-lock.yaml for the removed deps.
The pixelmatch/pngjs/@actions/core deps are now unused (compare_screenshots.js
was deleted in the assertScreenshot migration), but CI installs with
pnpm install --frozen-lockfile, so removing them from package.json without
regenerating pnpm-lock.yaml breaks the resources job. Keep them for now;
drop them with the next lockfile refresh.
Downloads the *-screenshots-* artifacts from a capture run (one triggered
with update_baselines=true) and drops them into maestro/images/expected/
{android,ios}/ keyed by basename, ready to review + commit.
…e jobs (Phase 2 pt2)

The project job now runs 'mxbuild --target=deploy --native-packager' to emit
the Android/iOS JS bundles (deployment/native/bundle/{android,iOS}) in
native-template-ready layout, alongside the existing portable-app-package run.

- Delete the android-bundle/ios-bundle jobs and the create-native-bundle action,
  which called mxbuild's internal @react-native-community/cli by hand from
  /tmp/mxbuild/modeler/tools/node/... (M5 — undocumented internals, brittle).
- Drop the now-unused 'mda' build + artifact (only create-native-bundle consumed it;
  test jobs use the portable app package).
- android-app/ios-app: needs [project, determine-nt-version], gate on project success;
  copy steps use 'cp -r bundles/<plat>/*' since the native-packager output is pre-split
  (android: assets/ + res/) per the official manual-build doc.

Same mxbuild compile-pass count as before (2), minus two jobs and a second
test-project download. mxbuild command + layout validated in Docker; the CI
app-build integration is validated by the next run.
prepare_android.sh: replace the blind 'sleep 30' with adb wait-for-device +
a bounded poll on sys.boot_completed and package-manager readiness, so the
shard proceeds the moment the emulator is up and fails fast (300s) if it
never boots instead of always paying 30s.

prepare_ios.sh: screenshot baselines are device/resolution-specific, so
prefer a pinned device (PREFERRED_IOS_DEVICE, default 'iPhone 17 Pro') for
reproducible visual regression; if it's not on the runner image, fall back
to the newest available iPhone and emit a ::warning:: to regenerate
baselines — graceful degradation instead of the old hardcoded hard-fail,
without the silent drift of pure newest-detection. Also fixes a latent
parse bug: device names contain spaces, so switch the simctl selector to
tab-delimited output.
…scoping (S7)

The resources job rebuilt every scoped widget in a sequential bash loop with
no cross-run caching. Now:

- determine-widget-scope.sh emits a full_build flag (true when every widget is
  built — infra-only/js-only/*-native/schedule; false for a specific subset).
- resources job restores a content-hashed dist cache (keyed on widget/JS-action
  src + package.json + pnpm-lock.yaml). On a full build with an exact hit (e.g.
  an infra-only PR), the rebuild is skipped entirely.
- Cache save is gated to full builds only, so a partial build can't populate the
  shared cache with an incomplete dist set under a key a later full build hits.
- The build itself now runs as one 'pnpm --filter=… --filter=…' invocation
  (concurrent) instead of one pnpm call per widget.

Held: full dorny/paths-filter scope rewrite (R6) remains a separate item.
The capture run's android-app/ios-app failed downloading android-bundle/
ios-bundle: the project job produced no bundle artifacts. Root cause —
ordering: 'deploy --native-packager' wrote deployment/native/bundle/{android,iOS}
first, then 'portable-app-package' ran and cleaned the deployment/ directory,
wiping the bundles before upload (uploads then warned 'no files found' and the
job stayed green, so apps failed later).

Fix: build portable-app-package FIRST (app.zip lives outside deployment/, so the
later deploy clean doesn't touch it), then deploy --native-packager LAST so
nothing cleans deployment/ afterward. Add if-no-files-found: error to the bundle
uploads so a missing/incorrect path fails the project job fast instead of
silently, plus an ls/find of the bundle output for diagnostics.
Re-capture on the pinned iPhone 17 Pro (iOS) and android emulator. Drop 4
orphaned baselines no current flow emits (activity_indicator x2,
SafeAreaViewMaps, accordion_on_change). iOS background-image-native /
popup-menu-native baselines retained from prior run (their capture jobs timed
out); to be re-captured.
…dget-test cap

- cores: 2 as an action input. The -cores 4 emulator-option was silently
  overridden by config.ini hw.cpu.ncore; free runners have 4 vCPU, so leave 2
  for the host (adb/Maestro/bundler).
- Android widget-test matrix max-parallel 4->5 (GitHub Free 20-total job cap;
  iOS stays 4 under the macOS-5 concurrent cap).
- Widget-test timeout-minutes 60->15 on both platforms — backstop for the
  fast-fail smoke check so a hung widget can't grind to the old 60-min wall.
…n (S6)

- New Smoke.yaml + smoke_check() run once per shard before any flows; a broken
  build fails in ~1 min instead of grinding every flow x retries.
- Precondition.yaml extendedWaitUntil 240s->60s and maxRetries 3->2 (was up to
  12 min PER flow — the real amplifier behind 60-min broken shards).
- prepare_ios.sh: export PREFERRED_IOS_DEVICE so the piped python3 inherits it
  (a VAR=x cmd | python prefix only applies to cmd), fixing the always-fallback
  device selection.
…eanup

- Android: add --build-cache to gradlew so RN libs' native compiles are reused
  via Gradle's local build cache (~/.gradle/caches/build-cache-1, already
  persisted by setup-java's cache: gradle).
- iOS: add COMPILATION_CACHE_ENABLE_CACHING=YES to xcodebuild → Xcode 26 LLVM-CAS
  compilation cache. It lives under the build/ derivedDataPath, already persisted
  by the existing 'Cache iOS Build' step, and survives clean (unlike DerivedData
  product reuse). Requires Xcode 26 (macos-26 ships it).
- Quote $GITHUB_OUTPUT / $GITHUB_ENV redirect targets and the mpk-move paths
  (shellcheck SC2086) across the version/scope/mpk run blocks.

Caches are no-ops on miss; a 2-run validation is needed to confirm 2nd-run hits.
vadim-v added 12 commits June 24, 2026 20:21
Fresh popup_menu_alert.png (android + iOS iPhone 17 Pro) from a scoped
update_baselines capture. background-image-native baselines deferred — its
capture wedges the app mid-suite (likely a widget issue, under investigation).
…ck retry

- widget-test timeout 15->20 (iOS sim/driver/runtime startup overhead was
  truncating slow-but-healthy shards at 15; fast-fail intent preserved)
- aggregate-test-results job merges per-shard screenshots/logs/artifacts into
  single all-screenshots / all-runtime-logs / all-debug-artifacts downloads
- smoke_check() retries once with a driver/sim reset to absorb transient
  Maestro XCUITest attach flakes without re-opening the runaway-hang window
…dd result summary

Phase 5 (D3/M4) — single toolchain:
- port determine-nt-version.py -> determine-nt-version.mjs (global fetch + a small
  dotted-version compare; no external deps), invoked via the runner's preinstalled node
- delete the dead setup-python composite action and the inline 'pip install requests packaging'
- fail loudly (exit 1) instead of the silent 'nt_branch=master' fallback when the Mendix
  version matches no range / the releases API call fails

Phase 4 cleanup + observability:
- remove unused pixelmatch / pngjs / @actions/core deps (dead since the assertScreenshot
  migration) and refresh pnpm-lock.yaml
- aggregate-test-results now writes a consolidated pass/fail table per shard to
  $GITHUB_STEP_SUMMARY
Record a screen video around every Maestro flow, keep it only when the flow
fails (delete on pass) so artifact storage holds just targeted clips of the
failing interaction. iOS uses `simctl io booted recordVideo` (SIGINT-finalized,
no length cap); Android uses `adb shell screenrecord --time-limit 180` started
after `adb root` so adbd restarts don't kill it. Smoke-check attempts are
recorded too. Toggle with RECORD_VIDEO=false.

archive-test-results uploads `*-videos-*`; aggregate-test-results merges them
into a single all-failure-videos artifact.

Also fix a latent bug: the four "Archive test results" steps lacked
`if: always()`, so a failing shard (run script exits non-zero) SKIPPED the
archive step -- screenshots/logs were captured only for green shards, never the
failing ones. Add `if: always()` to all four so failure artifacts actually
upload.
Split the BUILD scope from the TEST scope in determine-widget-scope.sh. A change
confined to a widget's e2e/ folder (Maestro flows + screenshots) changes only the
test, not the built artifact, so it no longer triggers a rebuild of that widget --
the widget is still added to the test matrix and runs against the test project's
baseline .mpk, exactly like every other not-rebuilt widget in a partial run.

The build set can now be empty (e.g. an e2e/infra-only PR), so add a
resources-manifest.txt step + include it in both resources upload paths to keep the
artifact non-empty -- otherwise the project job's download would fail.
Remove COMPILATION_CACHE_ENABLE_CACHING=YES from the iOS xcodebuild command. It
was added speculatively (its own commit noted a 2-run validation was still needed)
and never paid off: step timings show the build itself is ~95% of the ios-app job
and varies ~9 min run-to-run independent of this flag, while cross-run DerivedData
reuse on ephemeral runners is unreliable -- so CAS mostly adds compile-unit
hashing/write overhead with no reuse benefit. The DerivedData and Pods caches are
kept (their restore/save overhead is ~1 min total).
…o 30m

The Android per-flow video recording left the on-device `screenrecord` running:
stop_recording only killed the local `adb shell` client, so the recorder kept
going until its --time-limit (180s) and `wait` blocked ~2.5 min PER FLOW. That
silently added ~15 min to multi-flow shards and pushed them past the 20-min cap
(mass "cancelled" widget shards). Fix: `adb shell pkill -INT screenrecord` to make
the recorder finalize the mp4 promptly, then a bounded client kill so we never
block on the time-limit again.

Also raise the android-widget-tests cap 20→30 min as a hard backstop. With the
recording bug fixed a healthy shard finishes far under this; hitting 30 now means
real app instability, not cap tightness.
…conditionally

- Pin the mendix/Native-Mobile-Resources download to a commit SHA
  (NATIVE_MOBILE_RESOURCES_REF, single source of truth, overridable via a new
  test_project_ref dispatch input) instead of tracking a moving main; rename the
  ref-named archive dir back to Native-Mobile-Resources-main so downstream paths
  are unchanged. The repo publishes no tags, so a SHA is the only stable ref.
- Split "Move widgets": the mpk overlay stays guarded, but `mx update-widgets`
  now runs unconditionally so a js-actions dispatch (or any run that builds no
  mpks) no longer leaves stale Atlas widget defs for the portable-app build.
setup-tools no longer references mendix_version (the runtime-tarball + m2ee caches
that consumed it were removed when the portable app package replaced the
hand-rolled runtime). Remove the input declaration and the 4 call-site with: lines.
The scope job checked out with fetch-depth: 2 and passed github.event.before --
which is empty on pull_request events -- so the script fell back to
`git diff HEAD~1`, scoping a multi-commit PR off only its last commit (widgets
changed in earlier commits were silently dropped from build + test).

Check out full history (fetch-depth: 0) and pass the PR base SHA
(github.event.pull_request.base.sha); the script now diffs against the merge-base
with the base branch, covering every commit in the PR. Kept the per-widget
classifier and the dispatch path unchanged -- no new third-party action needed.
- determine-nt-version.mjs (Python toolchain removed)
- build-vs-test scope split (e2e-only changes test but don't build) + full_build output
- PR scoping now diffs against the base merge-base
…ial license

The portable-app runtime runs under a developer/trial license limited to 6
concurrent named users. Every Maestro flow does `launchApp clearState:true`,
which clears device state but opens a FRESH server session that lingers for the
default 10-min SessionTimeout. Widgets whose flow count (+smoke launch +retries)
exceeds 6 therefore wedge mid-suite on the "Maximum number of sessions exceeded"
screen — Precondition can't find the Widgets menu and the shard fails. This hit
background-image (8 flows), safe-area-view (7), accordion (6), and the js-tests
job (mobile-resources 5 + nanoflow 3 = 8); every <=5-flow widget stays green.

Fast-fail un-masked this rather than causing it: the old 4x-retry/long-timeout
config spread a shard's launches past the 10-min window, so early sessions
expired and freed slots before later retries ran (eventual green, just slow).

start-mendix-runtime now shortens the server session idle timeout
(RUNTIME_PARAMS_SESSIONTIMEOUT=60000, via a tunable session-timeout-ms input)
and reaps expired sessions promptly (RUNTIME_PARAMS_CLUSTERMANAGERACTIONINTERVAL
=30000), both ridden through the portable app's existing HOCON env overrides.
At ~30s/flow this holds peak concurrency to ~2-3, well under 6, with no test
changes. Confirmed against run 28168923730's safe-area-view runtime log.

Also reword script/action/workflow comments and the scripts README to describe
the current design instead of narrating what it replaced.
@vadim-v
vadim-v requested a review from a team as a code owner June 25, 2026 13:47
@vadymv-mendix
vadymv-mendix merged commit fb3c1eb into mendix:main Jun 26, 2026
103 of 112 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants