.github/.github/workflows/ci-mobile-ios.yaml
renovate[bot] e5b0cf714d
Some checks failed
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
chore(deps): update github actions (#134)
2026-08-24 21:18:32 +00:00

307 lines
12 KiB
YAML

name: CI — Mobile iOS
# Reusable CI counterpart to cd-mobile-ios.yaml. Verifies a React Native iOS
# app before merge: dependency install, typecheck, optional lint, optional unit
# tests, and a compile that is neither signed nor uploaded.
#
# Emits the single `ci / ci` status context required by the org branch-
# protection rulesets. As in ci-python-app.yaml, `ci` is an aggregator job
# gated on `needs`, so a caller job keyed `ci` reports `ci / ci` and that one
# context is red whenever any job below it failed.
#
# Input names mirror cd-mobile-ios.yaml wherever the same concept exists:
# node-version, ruby-version, working-directory, cache-dependency-path,
# fastlane-lane.
#
# Runner split. The JS checks run on ubuntu-latest; only the native compile
# runs on macOS, because only that step needs Xcode and CocoaPods. The macOS
# runner is billed at a multiple of the Linux rate, so putting `npm ci` +
# typecheck + tests on Linux keeps the expensive runner to the one job that
# genuinely requires it. `macos-26` matches cd-mobile-ios.yaml's runner, so CI
# compiles on the same Xcode image the deploy builds on.
#
# The compile is a `xcodebuild build`, not `archive` + `export`: it passes
# CODE_SIGNING_ALLOWED=NO / CODE_SIGNING_REQUIRED=NO / CODE_SIGN_IDENTITY="",
# and there is no App Store Connect or fastlane match step anywhere in this
# file. It therefore needs no signing certificates and no secrets, which is why
# `workflow_call` here declares none.
#
# run-lint and run-tests default to FALSE. The only current caller of
# cd-mobile-ios (proposal-system, working-directory `mobile`) declares exactly
# five npm scripts — start, ios, android, typecheck, postinstall — so a lint or
# test script cannot be assumed to exist. Turn them on per repo once the
# scripts are there.
#
# Caller example:
# jobs:
# ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # v1.0.4
# with:
# working-directory: mobile
# cache-dependency-path: mobile/package-lock.json
on:
workflow_call:
inputs:
node-version:
description: "Node.js version to use"
type: string
default: "24"
ruby-version:
description: "Ruby version for CocoaPods / Fastlane"
type: string
default: "3.3"
working-directory:
description: "Directory containing the mobile project"
type: string
default: "."
cache-dependency-path:
description: "Path to package-lock.json for npm cache"
type: string
default: "package-lock.json"
run-typecheck:
description: "Run the typecheck npm script"
type: boolean
default: true
typecheck-script:
description: "npm script name for the TypeScript check"
type: string
default: "typecheck"
run-lint:
description: "Run the lint npm script. The script must exist in package.json."
type: boolean
default: false
lint-script:
description: "npm script name for the linter"
type: string
default: "lint"
run-tests:
description: "Run the test npm script. The script must exist in package.json."
type: boolean
default: false
test-script:
description: "npm script name for the unit tests"
type: string
default: "test"
run-ios-build:
description: "Compile the iOS app without signing it"
type: boolean
default: true
fastlane-lane:
description: "Fastlane lane to run instead of xcodebuild. Empty means call xcodebuild directly. The lane must not sign or upload."
type: string
default: ""
xcode-workspace:
description: "Path to the .xcworkspace, relative to working-directory. Empty means auto-detect the one under ios/."
type: string
default: ""
xcode-scheme:
description: "Xcode scheme to build. Empty means the workspace file name without its extension."
type: string
default: ""
xcode-configuration:
description: "Xcode build configuration"
type: string
default: "Debug"
js-timeout-minutes:
description: "Timeout in minutes for the JavaScript checks job"
type: number
default: 15
ios-timeout-minutes:
description: "Timeout in minutes for the iOS compile job"
type: number
default: 45
# Read-only: this workflow builds and tests, it publishes nothing and assumes
# no cloud role. No `id-token` — nothing here mints an OIDC token.
permissions:
contents: read
jobs:
js:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.js-timeout-minutes }}
# cancel-in-progress is TRUE: superseding a push should abandon the older
# CI run, which produces no external side effects.
#
# The trailing segment is the job id written out literally, NOT
# `${{ github.job }}`. In a called workflow that expression evaluates to the
# CALLER's job id, so every job here would resolve to the same group and,
# with cancel-in-progress on, cancel its own siblings. Observed live in
# pr-reviewer: `lint` was cancelled one second in by a sibling and the
# aggregator failed on the cancelled dependency.
concurrency:
group: ci-mobile-ios-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-js
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Install JS dependencies
run: npm ci
- name: Verify the requested npm scripts exist
env:
RUN_TYPECHECK: ${{ inputs.run-typecheck }}
TYPECHECK_SCRIPT: ${{ inputs.typecheck-script }}
RUN_LINT: ${{ inputs.run-lint }}
LINT_SCRIPT: ${{ inputs.lint-script }}
RUN_TESTS: ${{ inputs.run-tests }}
TEST_SCRIPT: ${{ inputs.test-script }}
run: |
node <<'NODE'
const { readFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const wanted = [
[process.env.RUN_TYPECHECK, process.env.TYPECHECK_SCRIPT],
[process.env.RUN_LINT, process.env.LINT_SCRIPT],
[process.env.RUN_TESTS, process.env.TEST_SCRIPT],
];
const missing = wanted
.filter(([enabled, name]) => enabled === "true" && name)
.map(([, name]) => name)
.filter((name) => !pkg.scripts?.[name]);
if (missing.length > 0) {
console.error(`Enabled but missing from package.json scripts: ${missing.join(", ")}`);
process.exit(1);
}
console.log("All enabled npm scripts are present.");
NODE
- name: Typecheck
if: ${{ inputs.run-typecheck }}
env:
TYPECHECK_SCRIPT: ${{ inputs.typecheck-script }}
run: npm run "${TYPECHECK_SCRIPT}"
- name: Lint
if: ${{ inputs.run-lint }}
env:
LINT_SCRIPT: ${{ inputs.lint-script }}
run: npm run "${LINT_SCRIPT}"
- name: Unit tests
if: ${{ inputs.run-tests }}
env:
TEST_SCRIPT: ${{ inputs.test-script }}
run: npm run "${TEST_SCRIPT}"
ios-build:
if: ${{ inputs.run-ios-build }}
runs-on: macos-26
timeout-minutes: ${{ inputs.ios-timeout-minutes }}
concurrency:
group: ci-mobile-ios-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-ios-build
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true
working-directory: ${{ inputs.working-directory }}
- name: Install JS dependencies
run: npm ci
- name: Install CocoaPods
run: bundle exec pod install --project-directory=ios
- name: Point the Xcode build phase at the runner's node
# React Native's "Bundle React Native code and images" build phase
# resolves node through .xcode.env.local. cd-mobile-ios.yaml gets this
# from the repo's Fastfile; the xcodebuild path below has no Fastfile
# step, so write it here.
run: |
set -euo pipefail
node_path="$(command -v node)"
printf 'export NODE_BINARY=%s\n' "${node_path}" > ios/.xcode.env.local
echo "NODE_BINARY set to ${node_path}"
- name: Build without signing
if: ${{ inputs.fastlane-lane == '' }}
env:
XCODE_WORKSPACE: ${{ inputs.xcode-workspace }}
XCODE_SCHEME: ${{ inputs.xcode-scheme }}
XCODE_CONFIGURATION: ${{ inputs.xcode-configuration }}
run: |
set -euo pipefail
workspace="${XCODE_WORKSPACE}"
if [ -z "${workspace}" ]; then
workspace="$(find ios -maxdepth 1 -name '*.xcworkspace' | head -n 1)"
fi
if [ -z "${workspace}" ] || [ ! -d "${workspace}" ]; then
echo "::error::No .xcworkspace found. Set xcode-workspace explicitly."
exit 1
fi
scheme="${XCODE_SCHEME}"
if [ -z "${scheme}" ]; then
scheme="$(basename "${workspace}" .xcworkspace)"
fi
echo "Building workspace ${workspace}, scheme ${scheme}, configuration ${XCODE_CONFIGURATION}."
# `build`, not `archive`: no .ipa is produced and nothing is exported.
# The three CODE_SIGN* settings turn signing off entirely, so this
# needs no certificates and cannot upload anything.
xcodebuild build \
-workspace "${workspace}" \
-scheme "${scheme}" \
-configuration "${XCODE_CONFIGURATION}" \
-destination 'generic/platform=iOS' \
-derivedDataPath "${RUNNER_TEMP}/DerivedData" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
- name: Build via Fastlane
if: ${{ inputs.fastlane-lane != '' }}
env:
FASTLANE_LANE: ${{ inputs.fastlane-lane }}
run: |
set -euo pipefail
# Deliberately word-split: `fastlane-lane` carries a platform and a
# lane ("ios build") that fastlane expects as two separate argv
# entries, exactly as cd-mobile-ios.yaml passes it. Quoting would
# send one argument and fastlane would not find the lane.
# shellcheck disable=SC2086
bundle exec fastlane ${FASTLANE_LANE}
ci:
# Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`.
needs: [js, ios-build]
if: always()
runs-on: ubuntu-latest
concurrency:
group: ci-mobile-ios-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-ci
cancel-in-progress: true
steps:
- name: Require all jobs to have succeeded
run: |
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
echo "A required CI job failed or was cancelled."
exit 1
fi
echo "All CI jobs passed."