mattermost-mobile-upstream/.github/workflows/e2e-detox-pr.yml
yasser khan f3af9dbe99
Fix/detox hittest cluster (#10023)
fix(e2e): stabilize Detox and Maestro test suite

Restore main features, and harden the E2E harness against flaky UI, network, retry, synchronization, and classification-lock failures.

Stabilize channel creation, saved messages, bookmarks, search, deep links, keyboard dismissal, and post verification.
Bound network, transport, health-check, synchronization, and lock retries by elapsed time.
Fix iOS and Android Maestro/Detox races and flaky interaction flows.
Restore Session Attributes and deep-link fixes lost during merge.
Improve test isolation, global-setting cleanup, and Jest/TSIO reporting.
Enforce Maestro flow-header validation in local and CI checks.
2026-08-27 08:58:03 +05:30

1101 lines
46 KiB
YAML

# Matterwick E2E entry point (workflow_dispatch only).
#
# Matterwick dispatches this file via:
# POST /repos/{owner}/{repo}/actions/workflows/e2e-detox-pr.yml/dispatches
# and watches workflow_run "completed" events where workflow name is "E2E".
#
# Despite the `-pr` filename this is the entry point for *every* Matterwick E2E
# dispatch, not just PRs: run_type selects the flavour (PR, MAIN/MASTER for
# main-branch pushes, RELEASE). Renaming the file would break the dispatch URL
# above, so it needs a coordinated Matterwick change.
#
# Newer dispatches cancel older in-flight runs on the same branch via the workflow
# `concurrency` group below, so the latest SHA starts immediately instead of
# queueing behind a long run. MAIN/MASTER dispatches are keyed per commit instead,
# so consecutive merges to main never cancel each other.
#
# Phase 1 — builds & setup (parallel):
# build-ios-simulator, build-android-apk-maestro, build-android-apk (Detox debug APK),
# provision-servers, validate-maestro-flow-headers
# Phase 2 — tests (parallel, after phase 1):
# run-detox-ios / run-detox-android → e2e-detox.yml
# run-maestro-ios / run-maestro-android → e2e-maestro-pr.yml (downloads phase-1 artifacts via artifact_run_id)
name: E2E
on:
workflow_dispatch:
inputs:
MOBILE_VERSION:
description: "The mobile app commit SHA to test"
required: true
type: string
PLATFORM:
description: "Mobile platform to test: ios, android, or both"
required: false
default: "both"
type: choice
options:
- ios
- android
- both
ANDROID_SITE_1_URL:
description: "URL of Android test server A (provisioned by Matterwick)"
required: true
type: string
ANDROID_SITE_2_URL:
description: "URL of Android test server B (provisioned by Matterwick)"
required: true
type: string
IOS_SITE_1_URL:
description: "URL of iOS test server A (provisioned by Matterwick)"
required: true
type: string
IOS_SITE_2_URL:
description: "URL of iOS test server B (provisioned by Matterwick)"
required: true
type: string
SITE_3_URL:
description: "URL of the shared fifth Mattermost test server"
required: true
type: string
pr_number:
description: "PR number to remove E2E label from after tests complete"
required: false
type: string
run_type:
description: "Run type for test reporting (PR/RELEASE/MAIN). MASTER is accepted as an alias for MAIN (Matterwick main-push)."
required: false
default: "PR"
type: string
version_name:
description: "Branch or version ref under test (e.g. feature-branch, main, release-2.40). Used as TSIO branch — not pr-{number}."
required: false
type: string
# Default least-privilege token; jobs that upload artifacts, post statuses, or
# manage PR labels declare the extra scopes they need.
permissions:
contents: read
# test-system-io-report-upload (in the nested detox templates) authenticates
# via the workflow's OIDC token and resolves gh_job_id via the GitHub API.
# Reusable workflows cannot escalate beyond the caller, so the root caller
# must grant these.
id-token: write
actions: read
# One E2E run per branch for PRs, but per-COMMIT for main/release dispatches:
# Matterwick dispatches MAIN/MASTER runs with ref=main (matterwick
# server/push_events.go:319), so keying on github.ref alone would make a later
# merge cancel an earlier commit's still-running E2E — and a cancelled run never
# writes its build-cache entry, which is the scope PRs read from.
#
# For a PR the group stays per-branch (github.ref is the PR head branch, unique
# per PR), so a newer Matterwick dispatch still supersedes the one already in
# flight — cancelled by GitHub as the new run is created, which is earlier and
# simpler than cancelling from a step inside the new run.
concurrency:
group: e2e-${{ (inputs.run_type == 'MAIN' || inputs.run_type == 'MASTER') && inputs.MOBILE_VERSION || github.ref }}
cancel-in-progress: true
env:
# Changing this switches the iOS build path. Bump CACHE_VERSION in
# .github/actions/compute-e2e-build-fingerprint/action.yml or cached
# binaries built under the previous INTUNE setting will be served.
INTUNE_ENABLED: 'false'
DETOX_AWS_S3_BUCKET: "mattermost-detox-report"
jobs:
# Compute content fingerprints for S3 build caching.
compute-build-fingerprints:
name: Compute build fingerprints
runs-on: ubuntu-24.04
outputs:
ios: ${{ steps.ios.outputs.fingerprint }}
android-detox: ${{ steps.android-detox.outputs.fingerprint }}
android-maestro: ${{ steps.android-maestro.outputs.fingerprint }}
cache-scope: ${{ steps.scope.outputs.scope }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
persist-credentials: false
# Cache trust scope. Only main-push runs write the `main` scope that every
# other run reads; a PR writes (and additionally reads) only `pr-<n>`, so
# one PR's build can never be served to main or to another PR.
- name: Resolve cache trust scope
id: scope
env:
RUN_TYPE: ${{ inputs.run_type || 'PR' }}
PR_NUMBER: ${{ inputs.pr_number }}
VERSION_NAME: ${{ inputs.version_name }}
REF_NAME: ${{ github.ref_name }}
run: |
set -eu
# Matterwick still sends MASTER for main-branch pushes; treat as MAIN.
if [ "$RUN_TYPE" = "MAIN" ] || [ "$RUN_TYPE" = "MASTER" ]; then
scope=main
elif [ -n "$PR_NUMBER" ]; then
# Digits only — a crafted pr_number must not be able to escape the prefix.
pr=$(printf '%s' "$PR_NUMBER" | tr -cd '0-9')
if [ -z "$pr" ]; then
echo "::error::pr_number '$PR_NUMBER' contains no digits — cannot build a safe cache scope."
exit 1
fi
scope="pr-${pr}"
else
raw="${VERSION_NAME:-$REF_NAME}"
slug=$(printf '%s' "$raw" | tr -c 'a-zA-Z0-9._-' '-' | cut -c1-60)
scope="branch-${slug}"
fi
echo "scope=$scope" >> "$GITHUB_OUTPUT"
echo "Cache trust scope: $scope"
- name: Compute iOS fingerprint
id: ios
uses: ./.github/actions/compute-e2e-build-fingerprint
with:
platform: ios
- name: Compute Android Detox fingerprint
id: android-detox
uses: ./.github/actions/compute-e2e-build-fingerprint
with:
platform: android-detox
- name: Compute Android Maestro fingerprint
id: android-maestro
uses: ./.github/actions/compute-e2e-build-fingerprint
with:
platform: android-maestro
resolve-e2e-policy:
name: Resolve E2E policy (Override label)
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
issues: read
outputs:
e2e_override: ${{ steps.policy.outputs.e2e_override }}
skip_reason: ${{ steps.policy.outputs.skip_reason }}
steps:
# Per-branch cancellation used to happen here via cancel-e2e-runs (removed —
# see the workflow-level `concurrency:` group above, which supersedes it via
# native GitHub Actions cancellation). That step also guarded main-branch runs
# against cancelling each other; the concurrency group keys MAIN/MASTER by
# MOBILE_VERSION for the same reason, since Matterwick dispatches those with
# ref=main.
- name: Check PR labels for E2E/Override
id: policy
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ inputs.pr_number }}
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
with:
script: |
const prInput = parseInt(process.env.PR_NUMBER, 10);
let prNumber = Number.isFinite(prInput) ? prInput : null;
if (!prNumber && process.env.MOBILE_VERSION) {
try {
const {data: prs} = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: process.env.MOBILE_VERSION,
});
const openPr = prs.find((pr) => pr.state === 'open');
if (openPr) {
prNumber = openPr.number;
core.info(`Resolved PR #${prNumber} from commit ${process.env.MOBILE_VERSION}`);
}
} catch (err) {
core.warning(`Could not resolve PR from commit: ${err.message}`);
}
}
if (!prNumber) {
core.info('No PR number available — assuming E2E/Override is not set.');
core.setOutput('e2e_override', 'false');
core.setOutput('skip_reason', '');
return;
}
const {data: pr} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.ref !== 'main') {
core.info(`PR #${prNumber} targets ${pr.base.ref}, not main — skipping E2E.`);
core.setOutput('e2e_override', 'true');
core.setOutput('skip_reason', 'non-main-base');
return;
}
const {data: labels} = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const override = labels.some((label) => label.name === 'E2E/Override');
core.info(`PR #${prNumber} E2E/Override=${override}`);
core.setOutput('e2e_override', override ? 'true' : 'false');
core.setOutput('skip_reason', override ? 'override-label' : '');
# Every other job is skipped when the policy says "no E2E", and a skipped job
# posts nothing — the required e2e-test/* contexts would stay absent and block
# the merge forever. Post success here so the override actually lets it merge.
# Also covers the dispatch race where the label lands after Matterwick dispatched.
# Idempotent with the label manager, which posts the same statuses on `labeled`.
post-e2e-override-status:
name: Publish success on required E2E contexts (override)
runs-on: ubuntu-24.04
needs: resolve-e2e-policy
if: >-
needs.resolve-e2e-policy.outputs.e2e_override == 'true'
&& (inputs.run_type == 'PR' || inputs.run_type == 'MAIN' || inputs.run_type == 'MASTER')
permissions:
contents: read
statuses: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/e2e-override-status
with:
commit_sha: ${{ inputs.MOBILE_VERSION }}
description: >-
${{ needs.resolve-e2e-policy.outputs.skip_reason == 'non-main-base'
&& 'E2E skipped — PR does not target main'
|| 'e2e tests overridden' }}
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
calculate-tsio-identity:
name: Calculate TSIO per-job identities
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
needs: resolve-e2e-policy
if: needs.resolve-e2e-policy.outputs.e2e_override != 'true'
outputs:
job-configs: ${{ steps.id.outputs.job-configs }}
job-keys: ${{ steps.id.outputs.job-keys }}
steps:
- name: Sparse checkout TSIO job-config helper
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
persist-credentials: false
sparse-checkout: |
detox/utils/build-tsio-job-config.js
- name: Resolve branch ref for TSIO display URL
id: branch
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
VERSION_NAME: ${{ inputs.version_name }}
PR_NUMBER: ${{ inputs.pr_number }}
RUN_TYPE: ${{ inputs.run_type || 'PR' }}
with:
script: |
const versionName = (process.env.VERSION_NAME || '').trim();
if (versionName) {
core.setOutput('branch', versionName.replace(/^refs\/heads\//, '').replace(/^refs\/tags\//, ''));
return;
}
const runType = (process.env.RUN_TYPE || 'PR').toUpperCase();
// Matterwick still sends MASTER for main-branch pushes; treat as MAIN.
if (runType === 'MAIN' || runType === 'MASTER') {
core.setOutput('branch', 'main');
return;
}
const prInput = parseInt(process.env.PR_NUMBER, 10);
if (Number.isFinite(prInput)) {
const {data: pr} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prInput,
});
core.setOutput('branch', pr.head.ref);
return;
}
core.setOutput('branch', context.ref_name || 'main');
- id: id
env:
PLATFORM: ${{ inputs.PLATFORM }}
GITHUB_REPOSITORY: ${{ github.repository }}
MM_SHA: ${{ inputs.MOBILE_VERSION }}
PR_NUMBER: ${{ inputs.pr_number }}
RUN_TYPE: ${{ inputs.run_type || 'PR' }}
BRANCH: ${{ steps.branch.outputs.branch }}
run: |
# Matterwick main-push still sends MASTER; canonicalize to MAIN → mobile-main.
if [ "${RUN_TYPE}" = "MASTER" ]; then
RUN_TYPE=MAIN
fi
RUN_TYPE_LOWER=$(echo "${RUN_TYPE}" | tr '[:upper:]' '[:lower:]')
RUN_NAME="mobile-${RUN_TYPE_LOWER}"
if [ -n "${PR_NUMBER}" ]; then
BASE=$(jq -nc \
--arg repo "$GITHUB_REPOSITORY" --arg sha "$MM_SHA" --arg run_id "$GITHUB_RUN_ID" \
--arg attempt "$GITHUB_RUN_ATTEMPT" --arg branch "$BRANCH" --arg pr "$PR_NUMBER" --arg name "$RUN_NAME" \
'{repository:$repo, commit_sha:$sha, gh_run_id:$run_id, gh_run_attempt:$attempt, branch:$branch, name:$name, run_group:$name, gh_pr_number:$pr, framework:"detox"}')
else
BASE=$(jq -nc \
--arg repo "$GITHUB_REPOSITORY" --arg sha "$MM_SHA" --arg run_id "$GITHUB_RUN_ID" \
--arg attempt "$GITHUB_RUN_ATTEMPT" --arg branch "$BRANCH" --arg name "$RUN_NAME" \
'{repository:$repo, commit_sha:$sha, gh_run_id:$run_id, gh_run_attempt:$attempt, branch:$branch, name:$name, run_group:$name, framework:"detox"}')
fi
export BASE PLATFORM
JOB_CONFIGS=$(node -e '
const {buildTsioJobConfigMap, jobKeysForPlatform} = require("./detox/utils/build-tsio-job-config.js");
const base = JSON.parse(process.env.BASE);
const keys = jobKeysForPlatform(process.env.PLATFORM);
process.stdout.write(JSON.stringify(buildTsioJobConfigMap(base, keys)));
')
echo "job-configs=$JOB_CONFIGS" >> "$GITHUB_OUTPUT"
echo "job-keys=$(echo "$JOB_CONFIGS" | jq -r 'keys | join(",")')" >> "$GITHUB_OUTPUT"
update-initial-tsio-status:
name: Set pending per-job TSIO commit statuses
runs-on: ubuntu-24.04
needs:
- resolve-e2e-policy
- calculate-tsio-identity
if: >-
needs.resolve-e2e-policy.outputs.e2e_override != 'true'
&& needs.calculate-tsio-identity.result == 'success'
&& (inputs.run_type == 'PR' || inputs.run_type == 'MAIN' || inputs.run_type == 'MASTER')
permissions:
contents: read
statuses: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
persist-credentials: false
- name: Post pending status for each platform job
env:
GITHUB_TOKEN: ${{ github.token }}
JOB_CONFIGS: ${{ needs.calculate-tsio-identity.outputs.job-configs }}
COMMIT_SHA: ${{ inputs.MOBILE_VERSION }}
REPO: ${{ github.repository }}
TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
echo "$JOB_CONFIGS" | jq -c 'to_entries[]' | while read -r entry; do
CTX=$(echo "$entry" | jq -r '.value.status_context')
NAME=$(echo "$entry" | jq -r '.value.composite_identity.name')
gh api --method POST "repos/${REPO}/statuses/${COMMIT_SHA}" \
-f state=pending \
-f context="$CTX" \
-f description="Mobile E2E (${NAME}) — waiting for TSIO report" \
-f target_url="$TARGET_URL"
done
resolve-server-topology:
name: Resolve test server topology
runs-on: ubuntu-24.04
needs: resolve-e2e-policy
if: needs.resolve-e2e-policy.outputs.e2e_override != 'true'
outputs:
android-site-1-url: ${{ steps.topology.outputs.android-site-1-url }}
android-site-2-url: ${{ steps.topology.outputs.android-site-2-url }}
ios-site-1-url: ${{ steps.topology.outputs.ios-site-1-url }}
ios-site-2-url: ${{ steps.topology.outputs.ios-site-2-url }}
site-3-url: ${{ steps.topology.outputs.site-3-url }}
steps:
- name: Resolve five-server inputs
id: topology
env:
ANDROID_SITE_1_URL: ${{ inputs.ANDROID_SITE_1_URL }}
ANDROID_SITE_2_URL: ${{ inputs.ANDROID_SITE_2_URL }}
IOS_SITE_1_URL: ${{ inputs.IOS_SITE_1_URL }}
IOS_SITE_2_URL: ${{ inputs.IOS_SITE_2_URL }}
SITE_3_URL: ${{ inputs.SITE_3_URL }}
run: |
for name in ANDROID_SITE_1_URL ANDROID_SITE_2_URL IOS_SITE_1_URL IOS_SITE_2_URL SITE_3_URL; do
if [ -z "${!name}" ]; then
echo "::error::$name is required for the five-server topology"
exit 1
fi
done
{
echo "android-site-1-url=$ANDROID_SITE_1_URL"
echo "android-site-2-url=$ANDROID_SITE_2_URL"
echo "ios-site-1-url=$IOS_SITE_1_URL"
echo "ios-site-2-url=$IOS_SITE_2_URL"
echo "site-3-url=$SITE_3_URL"
} >> "$GITHUB_OUTPUT"
build-ios-simulator:
if: >-
!cancelled()
&& needs.resolve-e2e-policy.outputs.e2e_override != 'true'
&& (inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both')
# Runner / Xcode image is not in the git tree. Changing this label (or the
# Xcode on it) without bumping CACHE_VERSION in
# compute-e2e-build-fingerprint/action.yml serves stale cached binaries.
runs-on: macos-26
needs:
- resolve-e2e-policy
- compute-build-fingerprints
permissions:
contents: read
actions: write
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ inputs.MOBILE_VERSION }}
# S3 build cache — skip rebuild when fingerprint matches (test-only PRs hit the cache seeded by main-push runs).
- name: Check for cached iOS build
id: cache-check
continue-on-error: true
uses: ./.github/actions/s3-build-cache
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
fingerprint: ${{ needs.compute-build-fingerprints.outputs.ios }}
scope: ${{ needs.compute-build-fingerprints.outputs.cache-scope }}
platform: ios
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
cache-path: 'Mattermost-simulator-*.app.zip'
mode: restore
- name: Skip build if cache hit
if: steps.cache-check.outputs.cache-hit == 'true'
run: echo "SKIP_BUILD=true" >> "$GITHUB_ENV"
- name: Prepare iOS Build
if: env.SKIP_BUILD != 'true'
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Set .env with RUNNING_E2E=true
if: env.SKIP_BUILD != 'true'
run: echo "RUNNING_E2E=true" > .env
- name: Build iOS Simulator
if: env.SKIP_BUILD != 'true'
env:
TAG: ${{ inputs.MOBILE_VERSION }}
GITHUB_TOKEN: ${{ secrets.MM_MOBILE_GITHUB_TOKEN }}
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
# Always upload artifact (from cache or fresh build) so test jobs can download it.
- name: Upload iOS Simulator Build
if: always() && hashFiles('Mattermost-simulator-*.app.zip') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-*.app.zip
- name: Save iOS build to S3
if: env.SKIP_BUILD != 'true' && hashFiles('Mattermost-simulator-*.app.zip') != ''
continue-on-error: true
uses: ./.github/actions/s3-build-cache
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
fingerprint: ${{ needs.compute-build-fingerprints.outputs.ios }}
scope: ${{ needs.compute-build-fingerprints.outputs.cache-scope }}
platform: ios
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
cache-path: 'Mattermost-simulator-*.app.zip'
mode: save
build-android-apk:
if: >-
!cancelled()
&& needs.resolve-e2e-policy.outputs.e2e_override != 'true'
&& (inputs.PLATFORM == 'android' || inputs.PLATFORM == 'both')
# Runner image is not in the git tree — bump CACHE_VERSION in
# compute-e2e-build-fingerprint/action.yml when changing this label.
runs-on: ubuntu-latest-8-cores
needs:
- resolve-e2e-policy
- compute-build-fingerprints
permissions:
contents: read
actions: write
env:
ORG_GRADLE_PROJECT_jvmargs: -Xmx8g
steps:
- name: Prune Docker to free up space
run: docker system prune -af
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ inputs.MOBILE_VERSION }}
# S3 build cache — skip rebuild when fingerprint matches.
- name: Check for cached Android Detox build
id: cache-check
continue-on-error: true
uses: ./.github/actions/s3-build-cache
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
fingerprint: ${{ needs.compute-build-fingerprints.outputs.android-detox }}
scope: ${{ needs.compute-build-fingerprints.outputs.cache-scope }}
platform: android-detox
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
cache-path: 'android/app/build'
mode: restore
- name: Skip build if cache hit
if: steps.cache-check.outputs.cache-hit == 'true'
run: echo "SKIP_BUILD=true" >> "$GITHUB_ENV"
- name: Prepare Android Build
if: env.SKIP_BUILD != 'true'
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: ${{ secrets.MM_MOBILE_STORE_FILE }}
STORE_ALIAS: ${{ secrets.MM_MOBILE_STORE_ALIAS }}
STORE_PASSWORD: ${{ secrets.MM_MOBILE_STORE_PASSWORD }}
MATTERMOST_BUILD_GH_TOKEN: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}
# Cache Gradle dependencies — same key as e2e-detox-release.yml so the
# PR + release workflows share cache. Saves 3-5 min on warm runs by
# skipping gradle plugin resolution + Maven dep downloads.
- name: Cache Gradle dependencies
if: env.SKIP_BUILD != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /home/runner/.gradle/caches/modules-2/
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Detox build
if: env.SKIP_BUILD != 'true'
run: |
cd detox
npm ci --prefer-offline --no-audit --no-fund
npm run e2e:android-build
# Always upload artifact (from cache or fresh build) so test jobs can download it.
- name: Upload Android Build
if: always() && hashFiles('android/app/build/**/*') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-build-files-${{ github.run_id }}
path: "android/app/build/**/*"
- name: Save Android Detox build to S3
if: env.SKIP_BUILD != 'true' && hashFiles('android/app/build/**/*') != ''
continue-on-error: true
uses: ./.github/actions/s3-build-cache
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
fingerprint: ${{ needs.compute-build-fingerprints.outputs.android-detox }}
scope: ${{ needs.compute-build-fingerprints.outputs.cache-scope }}
platform: android-detox
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
cache-path: 'android/app/build'
mode: save
build-android-apk-maestro:
if: >-
!cancelled()
&& needs.resolve-e2e-policy.outputs.e2e_override != 'true'
&& (inputs.PLATFORM == 'android' || inputs.PLATFORM == 'both')
# Runner image is not in the git tree — bump CACHE_VERSION in
# compute-e2e-build-fingerprint/action.yml when changing this label.
runs-on: ubuntu-latest-8-cores
needs:
- resolve-e2e-policy
- compute-build-fingerprints
permissions:
contents: read
actions: write
env:
ORG_GRADLE_PROJECT_jvmargs: -Xmx8g
steps:
- name: Prune Docker to free up space
run: docker system prune -af
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ inputs.MOBILE_VERSION }}
# S3 build cache — skip rebuild when fingerprint matches.
- name: Check for cached Android Maestro build
id: cache-check
continue-on-error: true
uses: ./.github/actions/s3-build-cache
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
fingerprint: ${{ needs.compute-build-fingerprints.outputs.android-maestro }}
scope: ${{ needs.compute-build-fingerprints.outputs.cache-scope }}
platform: android-maestro
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
cache-path: 'android/app/build/outputs/apk/release/'
mode: restore
- name: Skip build if cache hit
if: steps.cache-check.outputs.cache-hit == 'true'
run: echo "SKIP_BUILD=true" >> "$GITHUB_ENV"
- name: Prepare Android Build
if: env.SKIP_BUILD != 'true'
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: ${{ secrets.MM_MOBILE_STORE_FILE }}
STORE_ALIAS: ${{ secrets.MM_MOBILE_STORE_ALIAS }}
STORE_PASSWORD: ${{ secrets.MM_MOBILE_STORE_PASSWORD }}
MATTERMOST_BUILD_GH_TOKEN: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}
- name: Build Maestro Android APK
if: env.SKIP_BUILD != 'true'
uses: ./.github/actions/build-android-maestro-apk
# Always upload artifact (from cache or fresh build) so test jobs can download it.
- name: Upload Maestro Android Build
if: always() && hashFiles('android/app/build/outputs/apk/release/*.apk') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-maestro-build-files-${{ github.run_id }}
path: android/app/build/outputs/apk/release/
- name: Save Android Maestro build to S3
if: env.SKIP_BUILD != 'true' && hashFiles('android/app/build/outputs/apk/release/*.apk') != ''
continue-on-error: true
uses: ./.github/actions/s3-build-cache
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
fingerprint: ${{ needs.compute-build-fingerprints.outputs.android-maestro }}
scope: ${{ needs.compute-build-fingerprints.outputs.cache-scope }}
platform: android-maestro
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
cache-path: 'android/app/build/outputs/apk/release/'
mode: save
provision-servers:
name: Provision test servers
needs:
- resolve-e2e-policy
- resolve-server-topology
if: >-
needs.resolve-e2e-policy.outputs.e2e_override != 'true'
runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
persist-credentials: false
- name: Collect server URLs
id: urls
env:
ANDROID_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.android-site-1-url }}
ANDROID_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.android-site-2-url }}
IOS_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.ios-site-1-url }}
IOS_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.ios-site-2-url }}
SITE_3_URL: ${{ needs.resolve-server-topology.outputs.site-3-url }}
run: |
{
echo "list<<EOF"
printf '%s\n' "$ANDROID_SITE_1_URL" "$ANDROID_SITE_2_URL" "$IOS_SITE_1_URL" "$IOS_SITE_2_URL" "$SITE_3_URL"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Provision test servers
uses: ./.github/actions/provision-e2e-servers
env:
ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }}
with:
server-urls: ${{ steps.urls.outputs.list }}
# iOS and Android Detox jobs are separate so each platform starts as soon as its
# own build finishes — Android does not wait for build-ios-simulator.
run-detox-ios:
name: Detox iOS E2E
needs:
- resolve-e2e-policy
- calculate-tsio-identity
- build-ios-simulator
- provision-servers
- resolve-server-topology
permissions:
contents: read
statuses: write
actions: read
id-token: write
# Finalize re-reads the PR labels for E2E/Override; a reusable workflow
# cannot escalate past the caller, so grant it here too.
issues: read
if: |
!cancelled() &&
needs.resolve-e2e-policy.outputs.e2e_override != 'true' &&
(inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both') &&
needs.provision-servers.result == 'success' &&
(needs.build-ios-simulator.result == 'success' || needs.build-ios-simulator.result == 'skipped')
uses: ./.github/workflows/e2e-detox.yml
with:
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
PLATFORM: ios
ANDROID_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.android-site-1-url }}
ANDROID_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.android-site-2-url }}
IOS_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.ios-site-1-url }}
IOS_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.ios-site-2-url }}
SITE_3_URL: ${{ needs.resolve-server-topology.outputs.site-3-url }}
run_type: ${{ inputs.run_type || 'PR' }}
artifact_run_id: ${{ github.run_id }}
tsio-job-configs: ${{ needs.calculate-tsio-identity.outputs.job-configs }}
secrets: inherit
run-detox-android:
name: Detox Android E2E
needs:
- resolve-e2e-policy
- calculate-tsio-identity
- build-android-apk
- provision-servers
- resolve-server-topology
permissions:
contents: read
statuses: write
actions: read
id-token: write
# Finalize re-reads the PR labels for E2E/Override; a reusable workflow
# cannot escalate past the caller, so grant it here too.
issues: read
if: |
!cancelled() &&
needs.resolve-e2e-policy.outputs.e2e_override != 'true' &&
(inputs.PLATFORM == 'android' || inputs.PLATFORM == 'both') &&
needs.provision-servers.result == 'success' &&
(needs.build-android-apk.result == 'success' || needs.build-android-apk.result == 'skipped')
uses: ./.github/workflows/e2e-detox.yml
with:
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
PLATFORM: android
ANDROID_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.android-site-1-url }}
ANDROID_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.android-site-2-url }}
IOS_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.ios-site-1-url }}
IOS_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.ios-site-2-url }}
SITE_3_URL: ${{ needs.resolve-server-topology.outputs.site-3-url }}
run_type: ${{ inputs.run_type || 'PR' }}
artifact_run_id: ${{ github.run_id }}
tsio-job-configs: ${{ needs.calculate-tsio-identity.outputs.job-configs }}
secrets: inherit
validate-maestro-flow-headers:
name: Validate Maestro flow headers
needs: resolve-e2e-policy
if: >-
needs.resolve-e2e-policy.outputs.e2e_override != 'true'
runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ inputs.MOBILE_VERSION }}
- name: Validate flow header contract
run: bash detox/maestro/scripts/validate-flow-headers.sh
# Maestro orchestration lives in e2e-maestro-pr.yml so Detox and Maestro CI
# can evolve independently. Maestro downloads ios-build-simulator-* and
# android-maestro-build-files-* (release APK with embedded bundle) from this
# workflow run via artifact_run_id.
# iOS and Android are separate jobs so each platform starts as soon as its
# own build finishes — Android does not wait for build-ios-simulator.
run-maestro-ios:
name: Maestro iOS E2E
needs:
- resolve-e2e-policy
- calculate-tsio-identity
- build-ios-simulator
- provision-servers
- validate-maestro-flow-headers
- resolve-server-topology
permissions:
contents: read
statuses: write
actions: read
id-token: write
# Finalize re-reads the PR labels for E2E/Override; a reusable workflow
# cannot escalate past the caller, so grant it here too.
issues: read
if: |
!cancelled() &&
needs.resolve-e2e-policy.outputs.e2e_override != 'true' &&
(inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both') &&
needs.provision-servers.result == 'success' &&
needs.validate-maestro-flow-headers.result == 'success' &&
(needs.build-ios-simulator.result == 'success' || needs.build-ios-simulator.result == 'skipped')
uses: ./.github/workflows/e2e-maestro-pr.yml
with:
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
PLATFORM: ios
ANDROID_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.android-site-1-url }}
ANDROID_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.android-site-2-url }}
IOS_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.ios-site-1-url }}
IOS_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.ios-site-2-url }}
SITE_3_URL: ${{ needs.resolve-server-topology.outputs.site-3-url }}
run_type: ${{ inputs.run_type || 'PR' }}
artifact_run_id: ${{ github.run_id }}
record_tests_in_zephyr: ${{ inputs.run_type == 'RELEASE' || inputs.run_type == 'MAIN' || inputs.run_type == 'MASTER' }}
tsio-job-configs: ${{ needs.calculate-tsio-identity.outputs.job-configs }}
secrets: inherit
run-maestro-android:
name: Maestro Android E2E
needs:
- resolve-e2e-policy
- calculate-tsio-identity
- build-android-apk-maestro
- provision-servers
- validate-maestro-flow-headers
- resolve-server-topology
permissions:
contents: read
statuses: write
actions: read
id-token: write
# Finalize re-reads the PR labels for E2E/Override; a reusable workflow
# cannot escalate past the caller, so grant it here too.
issues: read
if: |
!cancelled() &&
needs.resolve-e2e-policy.outputs.e2e_override != 'true' &&
(inputs.PLATFORM == 'android' || inputs.PLATFORM == 'both') &&
needs.provision-servers.result == 'success' &&
needs.validate-maestro-flow-headers.result == 'success' &&
(needs.build-android-apk-maestro.result == 'success' || needs.build-android-apk-maestro.result == 'skipped')
uses: ./.github/workflows/e2e-maestro-pr.yml
with:
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
PLATFORM: android
ANDROID_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.android-site-1-url }}
ANDROID_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.android-site-2-url }}
IOS_SITE_1_URL: ${{ needs.resolve-server-topology.outputs.ios-site-1-url }}
IOS_SITE_2_URL: ${{ needs.resolve-server-topology.outputs.ios-site-2-url }}
SITE_3_URL: ${{ needs.resolve-server-topology.outputs.site-3-url }}
run_type: ${{ inputs.run_type || 'PR' }}
artifact_run_id: ${{ github.run_id }}
record_tests_in_zephyr: ${{ inputs.run_type == 'RELEASE' || inputs.run_type == 'MAIN' || inputs.run_type == 'MASTER' }}
tsio-job-configs: ${{ needs.calculate-tsio-identity.outputs.job-configs }}
secrets: inherit
e2e-remove-matterwick-label:
name: Remove Matterwick E2E label from PR
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
pull-requests: write
needs:
- resolve-e2e-policy
- run-detox-ios
- run-detox-android
- run-maestro-ios
- run-maestro-android
# Strip E2E/Run* after tests finish unless Maestro was externally cancelled
# (leave the label so the suite can be re-triggered).
if: |
!cancelled() &&
needs.resolve-e2e-policy.outputs.e2e_override != 'true' &&
contains(fromJSON('["success","failure","skipped"]'), needs.run-detox-ios.result) &&
contains(fromJSON('["success","failure","skipped"]'), needs.run-detox-android.result) &&
contains(fromJSON('["success","failure","skipped"]'), needs.run-maestro-ios.result) &&
contains(fromJSON('["success","failure","skipped"]'), needs.run-maestro-android.result)
steps:
- name: e2e/remove-label-from-pr
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const runLabels = ['E2E/Run', 'E2E/Run-iOS', 'E2E/Run-Android'];
let prNumber = parseInt(process.env.PR_NUMBER, 10);
if (!Number.isFinite(prNumber) && process.env.MOBILE_VERSION) {
const {data: prs} = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: process.env.MOBILE_VERSION,
});
const openPr = prs.find((pr) => pr.state === 'open');
if (openPr) {
prNumber = openPr.number;
console.log(`Resolved PR #${prNumber} from commit ${process.env.MOBILE_VERSION}`);
}
}
if (!Number.isFinite(prNumber)) {
const branch = process.env.BRANCH;
if (branch) {
const {data: prs} = await github.rest.pulls.list({
owner,
repo,
head: `${owner}:${branch}`,
state: 'open',
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
}
if (!Number.isFinite(prNumber)) {
core.warning('No PR found — skipping label removal');
return;
}
const {data: workflows} = await github.rest.actions.listRepoWorkflows({owner, repo});
const e2eWorkflow = workflows.workflows.find((workflow) => workflow.name === 'E2E');
if (e2eWorkflow) {
// Cache PR lookups per head_sha to avoid quadratic API calls.
const prsForSha = new Map();
const lookupPRsForSha = async (sha) => {
if (!sha) return [];
if (prsForSha.has(sha)) return prsForSha.get(sha);
try {
const {data} = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: sha,
});
const numbers = (data || []).map((p) => p.number);
prsForSha.set(sha, numbers);
return numbers;
} catch (err) {
core.warning(`listPullRequestsAssociatedWithCommit failed for ${sha}: ${err.message}`);
prsForSha.set(sha, []);
return [];
}
};
for (const status of ['queued', 'in_progress', 'waiting']) {
const {data: activeRuns} = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: e2eWorkflow.id,
status,
branch: process.env.BRANCH,
});
for (const run of activeRuns.workflow_runs) {
if (run.id === context.runId) {
continue;
}
// Fall back to per-SHA PR lookup when run.pull_requests is empty (fork PRs / webhook timing).
let belongsToPR;
if (run.pull_requests?.length) {
belongsToPR = run.pull_requests.some((pr) => pr.number === prNumber);
} else {
const numbers = await lookupPRsForSha(run.head_sha);
belongsToPR = numbers.includes(prNumber);
}
if (belongsToPR) {
core.info(`PR #${prNumber} has another active E2E run — skipping label removal (run ${run.id}).`);
return;
}
}
}
}
console.log(`Removing E2E labels from PR #${prNumber} (workflow conclusion: ${process.env.WORKFLOW_CONCLUSION || 'unknown'})`);
for (const label of runLabels) {
try {
await github.rest.issues.removeLabel({
issue_number: prNumber,
owner,
repo,
name: label,
});
console.log(`Removed ${label}`);
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
}
env:
PR_NUMBER: ${{ inputs.pr_number }}
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
BRANCH: ${{ github.ref_name }}
WORKFLOW_CONCLUSION: ${{ job.status }}
# Per-platform TSIO commit statuses are finalized inside each Detox/Maestro
# generate-report / maestro job after that job's upload (total_reports_expected=1).
# No combined e2e/mobile finalizer — avoids cross-job clobber and pending races.
# One Mattermost channel rollup (best-effort). Per-job finalize leaves channel
# notify off so N legs do not spam; this job posts a single summary.
tsio-channel-notify:
name: Post Mattermost E2E channel rollup
runs-on: ubuntu-24.04
if: >-
always() &&
needs.resolve-e2e-policy.outputs.e2e_override != 'true' &&
needs.calculate-tsio-identity.result == 'success' &&
(inputs.run_type == 'PR' || inputs.run_type == 'MAIN' || inputs.run_type == 'MASTER')
needs:
- resolve-e2e-policy
- calculate-tsio-identity
- run-detox-ios
- run-detox-android
- run-maestro-ios
- run-maestro-android
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
persist-credentials: false
sparse-checkout: |
detox/utils/tsio-channel-notify-rollup.js
detox/utils/tsio-report-status.js
detox/utils/cmt-channel-notify.js
detox/utils/build-tsio-job-config.js
detox/utils/cli-args.js
- name: Aggregate per-job TSIO groups and notify channel
env:
JOB_CONFIGS: ${{ needs.calculate-tsio-identity.outputs.job-configs }}
MATTERMOST_E2E_WEBHOOK_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_URL }}
MATTERMOST_MASTER_HEALTH_WEBHOOK_URL: ${{ secrets.MM_E2E_MASTER_HEALTH_WEBHOOK_URL }}
MATTERMOST_CMT_WEBHOOK_URL: ${{ secrets.MM_E2E_RELEASE_WEBHOOK_URL }}
run: |
UPSTREAM="true"
for r in "${{ needs.run-detox-ios.result }}" "${{ needs.run-detox-android.result }}" "${{ needs.run-maestro-ios.result }}" "${{ needs.run-maestro-android.result }}"; do
case "$r" in
success|skipped) ;;
*) UPSTREAM="false" ;;
esac
done
node detox/utils/tsio-channel-notify-rollup.js \
--job-configs="$JOB_CONFIGS" \
--upstream-succeeded="$UPSTREAM" \
--poll-attempts=6 || true