This repository centralizes GitHub Actions, workflows, Fastlane lanes, and automation scripts to streamline SDK development across our Android, iOS, Flutter, React Native, and Expo repositories.
- 🚀 GitHub Actions & Workflows – Automate builds, tests, and deployments
- 📱 Fastlane Lanes – Manage SDK versioning, sample app builds, and releases
- 🔧 Utility Scripts – Support automation for CI/CD tasks
github-actions/ios/launch-simulator-app/v1 installs an existing simulator
.app, launches it on an available iPhone runtime, and fails when the process
does not remain alive for the configured survival window. It is a launch-crash
sentinel, not evidence of lifecycle callbacks, push delivery, signing, or App
Store compatibility.
The action terminates and uninstalls the app after the smoke test. On failure,
launch and survival failures write simulator diagnostics to the explicit
log-path input. The failure-reason output is one of invalid-input, invalid-app,
sdk-mismatch, runtime-unavailable, runtime-selection-failed,
simulator-boot-failed, install-failed, launch-failed, did-not-survive,
unexpected-error, or none after success. Outputs
that could not yet be determined use unknown. Consumers should pass a known
log-path, then add an if: failure() artifact-upload step for that same path
with if-no-files-found: ignore. A failed composite action is not required to
propagate its mapped outputs, so diagnostics upload must not depend on the
log-path output. If the action uses continue-on-error: true, gate that upload
on steps.<id>.outcome == 'failure' because the job-level failure() predicate
remains false. Use a distinct path for each invocation because the action
truncates its requested log before launch. The action intentionally does not replay app-controlled
simulator logs through the GitHub command parser. Consumers must also set a
job-level timeout-minutes, because CoreSimulator commands have no portable
macOS command-level timeout. The requested runtime major must match the built
app's normalized DTSDKName major. The app must use an Apple-conforming bundle
identifier containing only ASCII letters, digits, hyphens, and periods.
Hosted macOS runners provide the required Python 3, xcrun, ps, sleep, and
/usr/libexec/PlistBuddy tools. A self-hosted runner must provide the same tools.
The action assumes exclusive use of the selected simulator for the tested bundle identifier. Concurrent jobs sharing one simulator and bundle identifier can terminate or uninstall each other's fixture; serialize those jobs or use isolated simulators.
The failure log contains the tested app's own unified-log output. Consumers should use short artifact retention and must not exercise fixtures that emit real customer profiles, device tokens, or other sensitive data.
github-actions/wait-until-downloadable/v1 polls a public registry until one
exact version of every listed package can be downloaded. Run it between the job
that publishes a release and any job that installs that release.
wait-for-registry:
needs: deploy
runs-on: ubuntu-latest
timeout-minutes: 70 # Above the action's 60 minutes, so its report is not cut off.
steps:
- uses: customerio/mobile-ci-tools/github-actions/wait-until-downloadable/v1@main
with:
ecosystem: maven
version: ${{ needs.deploy.outputs.version }}
packages: |
io.customer.android:core
io.customer.android:datapipelines| Input | Required | Default | Purpose |
|---|---|---|---|
ecosystem |
yes | maven (Maven Central), npm, pub (pub.dev) or cocoapods (CocoaPods CDN). |
|
version |
yes | Exact release version, such as 5.0.0 or 3.14.2-beta.1. Dist-tags such as latest are rejected. |
|
packages |
yes | One per line. Maven packages are group:artifact. List every module the consumer resolves, including transitive modules from the same release. |
|
timeout-minutes |
no | 60 |
Whole minutes before the action fails. |
The action requests the same URLs the package manager does, over HTTPS only (including redirects) and without cache busting. A package counts as downloadable only when the files name the requested package and version:
- npm: the abbreviated packument lists the version, and the tarball matches
dist.integrity(sha512). - pub:
/api/packages/<name>lists the version, and the archive matchesarchive_sha256. - Maven: the POM coordinates match. When the POM says Gradle metadata was published, the
.modulecomponent must match too. The.aaror.jarmust download and match the.modulesha256 when one is listed. - CocoaPods: the CDN version index lists the version, and the podspec JSON names the pod and version.
Missing files, other HTTP statuses, network errors and mismatches are retried
with backoff from 10 to 60 seconds. Each request is limited to 30 seconds and to
the remaining budget. When the budget runs out, the action fails with one
::error:: that names each pending package and its last check, and writes a
table to the step summary. Invalid input fails before any request is made. The
action needs Python 3.9 or later as python3, and curl. Hosted Ubuntu and
macOS runners provide both.
.github/workflows/macos-runner-select.yml picks the runner label for a macOS
job. It returns the second pool's label when GitHub's macOS pool is busy with
mobile jobs, and the GitHub label otherwise. It runs on ubuntu-latest, so the
check never takes a Mac slot. Call it from PR and CI workflows only; release
jobs keep their fixed label and never call it.
jobs:
select-runner:
# Skipped while the switch is off, so the Mac job starts exactly as before.
if: vars.MACOS_OVERFLOW_ENABLED == 'true'
uses: customerio/mobile-ci-tools/.github/workflows/macos-runner-select.yml@main
with:
enabled: ${{ vars.MACOS_OVERFLOW_ENABLED }}
github-label: xcode-27
overflow-label: ${{ vars.MACOS_OVERFLOW_RUNNER_LABEL }}
# Optional spend check; leave these out to turn it off.
monthly-spend-cap: ${{ fromJSON(vars.MACOS_OVERFLOW_MONTHLY_CAP || '0') }}
spend-url: ${{ vars.MACOS_OVERFLOW_SPEND_URL }}
secrets:
spend-api-key: ${{ secrets.MACOS_OVERFLOW_SPEND_API_KEY }}
test:
needs: select-runner
# Runs when the selector succeeded, was skipped, or failed. Only a cancelled run stops it.
if: ${{ !cancelled() }}
uses: customerio/mobile-ci-tools/.github/workflows/ios-test.yml@main
with:
runner-label: ${{ needs.select-runner.outputs.label || 'xcode-27' }}
scheme: MyScheme
xcresult-name: MyScheme.xcresultA job with its own runs-on uses
runs-on: ${{ needs.select-runner.outputs.label || 'xcode-27' }} the same way.
The second pool's image must provide the Xcode version the job selects.
| Input | Required | Default | Purpose |
|---|---|---|---|
enabled |
no | 'false' |
The switch. Only the exact string true runs the check. Feed it from vars.MACOS_OVERFLOW_ENABLED. |
github-label |
yes | GitHub-hosted label the job uses today, such as xcode-27. |
|
overflow-label |
no | '' |
The second pool's label. Feed it from vars.MACOS_OVERFLOW_RUNNER_LABEL. Empty means the check does not run. |
running-threshold |
no | 13 |
Overflow when this many mobile Mac jobs, or more, are running on GitHub's pool. The org limit is 15, so 13 leaves 2 for other org repositories. |
queued-seconds |
no | 30 |
Overflow when a mobile Mac job has been queued on GitHub's pool for longer than this. |
monthly-spend-cap |
no | 0 |
The second pool's monthly spend cap, in the unit its spend endpoint reports. 0 turns the spend check off. Feed it from vars.MACOS_OVERFLOW_MONTHLY_CAP. |
spend-stop-percent |
no | 95 |
Keep jobs on GitHub once month-to-date spend reaches this percentage of the cap. |
spend-url |
no | '' |
https URL of the second pool's spend endpoint, without a query. Feed it from vars.MACOS_OVERFLOW_SPEND_URL. |
| Secret | Required | Purpose |
|---|---|---|
spend-api-key |
no | Bearer key for spend-url. Needed only when monthly-spend-cap is set. |
The mobile repositories are public, so the selector job's
own GITHUB_TOKEN, with no scopes, reads their runs and jobs. That token allows
1,000 API calls per hour per repository, shared with every other use of it in
the caller's repository. A selection that runs out gets the GitHub label, and
its note says it was rate limited.
| Output | Value |
|---|---|
label |
The label for runs-on or runner-label. |
reason |
switch-off, fork, no-credentials, invalid-input, api-error, spend-cap, spend-unknown, capacity, queued, or free. |
running |
Mobile Mac jobs counted as running on GitHub's pool. |
queued |
Mobile Mac jobs counted as queued there for longer than queued-seconds. |
The mobile repositories are customerio-ios, customerio-expo-plugin,
customerio-reactnative, customerio-flutter and customerio-ios-fcm. A Mac job
has a label starting with macos or xcode, in any case. Jobs that carry the
overflow label, or run in a runner group other than GitHub Actions, are on the
second pool and are not counted. For each repository the selector lists the
queued and then the in_progress runs, then reads each run's jobs, and stops
as soon as either signal fires. One selection makes 10 listing calls plus one
call per active run (and one per further page of a run with more than 100
jobs), capped at 40. A repository with more than 100 queued or running runs
counts as capacity without further reads. Each request is limited to 5 seconds and the
whole check to 20 seconds. The label, reason, counts and call count go to the
step summary.
With monthly-spend-cap set, a selection that would overflow first reads the
second pool's month-to-date spend: one request to spend-url with
start_date and end_date for the current UTC month, reading
summary.total_cost. A quiet pool never makes this request. The step summary
gives spend as a share of the cap, never as an amount. The key is passed only
to the selector job, which runs on GitHub's Ubuntu runners.
Every path other than capacity and queued returns the GitHub label:
switch-off:enabledis nottrue, oroverflow-labelis empty.fork: the pull request's head repository differs from the base repository.no-credentials: the action got an emptytoken. The reusable workflow always passes the job's own token.invalid-input: a threshold, label, repository name, spend cap, stop percentage orspend-urlis malformed.api-error: a request failed, timed out or was rate limited, a response could not be parsed, the call cap or time budget ran out, or the selector step itself failed. The note names the HTTP status, and says when it was a rate limit.spend-cap: the pool was busy, but month-to-date spend is at or abovespend-stop-percentof the cap.spend-unknown: the pool was busy and a cap is set, but spend could not be read: no URL or key, a failed request, or a response without a number.
The selector job does not fail on any of these: the selection step continues
on error, and the job's outputs fall back to the GitHub label. The
workflow loads github-actions/macos-runner-select/v1@main, which holds the
logic in select.sh and its stubbed tests in test.sh.
github-actions/slack-notify-scheduled-failure/v1 posts a failed scheduled
run to #squad-mobile-alerts. Add it as the last step of every job in a
scheduled workflow:
- name: Alert Slack on a failed scheduled run
timeout-minutes: 2
if: (failure() || cancelled()) && github.event_name == 'schedule'
uses: customerio/mobile-ci-tools/github-actions/slack-notify-scheduled-failure/v1@main
with:
slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
label: Inbox E2E # Optional. The job id is shown when it is empty.Set explicit step timeouts whose total stays below the job timeout, including
setup, evidence upload and the alert. For example, reserve 5 minutes of a
60-minute job for evidence and notification. A test step timeout makes
failure() true and leaves later steps able to run. A job timeout can stop
all remaining steps; adding cancelled() does not guarantee an alert then.
The condition also attempts an alert for a manually cancelled scheduled run.
Runner loss or failure during job setup still requires monitoring outside the job.
| Input | Required | Default | Purpose |
|---|---|---|---|
slack_webhook_url |
yes | A legacy Slack incoming webhook that honours the payload's channel field. Pass it from a secret. |
|
label |
no | job id | Shown in the message, for example a suite name or a matrix value. |
channel |
no | #squad-mobile-alerts |
Channel name or ID to post to. |
dry_run |
no | 'false' |
'true' writes the payload to the step summary and the log and posts nothing. |
The message shows the repository, workflow, label (or job id), run attempt,
short SHA and branch, and a link to that run attempt. The action posts only
when github.event_name is schedule. For any other event it logs a notice
and exits 0, so a mistaken caller if: still cannot post for pull request or
manual runs. Keep the caller if: anyway so the step is skipped.
Each job that runs the step posts once, so set label from the matrix value
in matrix jobs. A job skipped because a job it needs failed runs no steps,
so the alert comes from the job that failed. The payload is built with jq
and posted by slackapi/slack-github-action with errors: true, so a
rejected post marks the step as failed in the run.
GitHub loads every action a job references during "Set up job", including
steps whose if: is false. Every caller job therefore loads this action on
every run. action.yml must stay loadable at main, as a composite action
without pre: or post: steps.