Skip to content

Commit 0aad123

Browse files
committed
refactor(ci): extract setup-fixture-app so any job can use the cached app
The fixture-app build + cache was inline in the differential workflow, so nothing else could reach it. Extracted to a composite action mirroring setup-apple-replay, because the capability is what #320 has been missing: it wants replay coverage moved off Apple system apps onto a controlled fixture with stable ids, and that fixture (examples/test-app) already exists — CI just had no way to build and install it. The cache is genuinely shared. GitHub caches are per-repository and readable across workflows, and a run restores from its own branch or the default branch, so once a run on main populates it every workflow gets the hit and only the first one pays the ~22 minutes. The key is computed inside the action from a fixed input list and deliberately contains nothing caller-specific — folding a caller's workflow path into it would silently unshare the cache. Also removes a duplication risk: the action reads the bundle id from the built app's Info.plist rather than hardcoding it, so it cannot drift from what was actually built, and it fails loudly if the app is not installed. The conformance workflow keeps its own narrower assertion — that the installed id is the one its scenarios target — since that is its concern, not the action's. Usage: - uses: ./.github/actions/setup-fixture-app with: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} # outputs: app-path, app-id, cache-hit
1 parent b5b03b7 commit 0aad123

2 files changed

Lines changed: 131 additions & 59 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
name: "Setup Fixture App"
2+
description: "Build (or restore from cache) the examples/test-app fixture app and install it on the booted iOS simulator"
3+
4+
# Any job that needs a controlled app to drive can use this instead of an Apple
5+
# system app. Building it costs ~22 minutes, so the built .app is cached and the
6+
# build only runs when its sources or the toolchain actually change.
7+
#
8+
# The cache is shared: GitHub caches are per-repository and readable across
9+
# workflows, and a run can restore caches from its own branch or the default
10+
# branch. So once a run on main populates it, every workflow gets the hit. The
11+
# key is computed here from a fixed input list, so all callers agree on it —
12+
# do not fold caller-specific inputs into it, or the cache stops being shared.
13+
#
14+
# Relevant to #320 (move replay coverage off system apps onto a stable fixture).
15+
16+
inputs:
17+
runtime-version:
18+
description: "iOS runtime version (participates in the cache key)"
19+
required: true
20+
device-name:
21+
description: "Simulator device name used for the build destination"
22+
required: false
23+
default: "iPhone 17 Pro"
24+
install:
25+
description: "Install the app onto the booted simulator"
26+
required: false
27+
default: "true"
28+
29+
outputs:
30+
app-path:
31+
description: "Path to the built .app bundle"
32+
value: ${{ steps.locate.outputs.app-path }}
33+
app-id:
34+
description: "Bundle identifier read from the built app's Info.plist"
35+
value: ${{ steps.locate.outputs.app-id }}
36+
cache-hit:
37+
description: "Whether the build was restored from cache"
38+
value: ${{ steps.cache.outputs.cache-hit }}
39+
40+
runs:
41+
using: "composite"
42+
steps:
43+
- name: Resolve Xcode cache key
44+
id: xcode
45+
shell: bash
46+
run: |
47+
set -euo pipefail
48+
XCODE_KEY="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//' | tr ' ' '-' | tr -cd '[:alnum:]._-')"
49+
echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT"
50+
51+
- name: Cache fixture app build
52+
id: cache
53+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3
54+
with:
55+
path: ${{ github.workspace }}/.tmp/fixture-app
56+
# Everything that can change the binary: app sources, native config,
57+
# dependency graph, this action's build logic, and the toolchain.
58+
# Deliberately caller-independent so the cache is shared across workflows.
59+
key: fixture-app-ios-${{ inputs.runtime-version }}-${{ steps.xcode.outputs.key }}-${{ hashFiles('examples/test-app/src/**', 'examples/test-app/app/**', 'examples/test-app/modules/**', 'examples/test-app/app.config.js', 'examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/actions/setup-fixture-app/action.yml') }}
60+
61+
- name: Build fixture app
62+
if: steps.cache.outputs.cache-hit != 'true'
63+
shell: bash
64+
run: |
65+
set -euo pipefail
66+
# Release embeds the JS bundle, so no Metro server is needed in CI.
67+
pnpm test-app:install
68+
pnpm --dir examples/test-app exec expo run:ios \
69+
--configuration Release \
70+
--device "${{ inputs.device-name }}" \
71+
--no-bundler
72+
# Stash the bundle at a stable path: DerivedData paths are hashed per
73+
# project, so the cache needs somewhere deterministic to keep it.
74+
APP_PATH="$(find "$HOME/Library/Developer/Xcode/DerivedData" -type d -name '*.app' -path '*Release-iphonesimulator*' | head -1)"
75+
if [ -z "$APP_PATH" ]; then
76+
echo "::error::Built the fixture app but could not locate its .app bundle to cache."
77+
exit 1
78+
fi
79+
mkdir -p "${{ github.workspace }}/.tmp/fixture-app"
80+
rm -rf "${{ github.workspace }}/.tmp/fixture-app/"*.app
81+
cp -R "$APP_PATH" "${{ github.workspace }}/.tmp/fixture-app/"
82+
83+
- name: Locate fixture app
84+
id: locate
85+
shell: bash
86+
run: |
87+
set -euo pipefail
88+
APP="$(find "${{ github.workspace }}/.tmp/fixture-app" -maxdepth 1 -type d -name '*.app' | head -1)"
89+
if [ -z "$APP" ]; then
90+
echo "::error::No fixture app bundle at .tmp/fixture-app (restored an empty cache?)."
91+
exit 1
92+
fi
93+
# Read the id from the bundle itself rather than duplicating it here, so
94+
# this cannot drift from what was actually built.
95+
APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")"
96+
echo "app-path=$APP" >> "$GITHUB_OUTPUT"
97+
echo "app-id=$APP_ID" >> "$GITHUB_OUTPUT"
98+
echo "fixture app: $(basename "$APP") ($APP_ID)"
99+
100+
- name: Install fixture app
101+
if: inputs.install == 'true'
102+
shell: bash
103+
run: |
104+
set -euo pipefail
105+
xcrun simctl install booted "${{ steps.locate.outputs.app-path }}"
106+
# Fail loudly rather than let a caller drive a device with no app: a
107+
# missing app produces scenarios that fail for the wrong reason, or pass
108+
# while proving nothing.
109+
if ! xcrun simctl get_app_container booted "${{ steps.locate.outputs.app-id }}" >/dev/null 2>&1; then
110+
echo "::error::Fixture app ${{ steps.locate.outputs.app-id }} is not installed on the booted simulator."
111+
exit 1
112+
fi
113+
echo "installed ${{ steps.locate.outputs.app-id }}"

.github/workflows/conformance-differential.yml

Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -78,72 +78,31 @@ jobs:
7878
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
7979
preferred-device-name: iPhone 17 Pro
8080

81-
# Building the fixture app costs ~22 minutes — 79% of this job — for an app
82-
# that changes almost never, so cache the built .app and rebuild only when
83-
# its sources or the toolchain change. Mirrors the setup-apple-replay
84-
# prebuilt-runner cache. A miss simply falls back to the full build.
85-
- name: Resolve Xcode cache key
86-
id: xcode
87-
run: |
88-
set -euo pipefail
89-
XCODE_KEY="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//' | tr ' ' '-' | tr -cd '[:alnum:]._-')"
90-
echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT"
91-
92-
- name: Cache fixture app build
93-
id: fixture-app-cache
94-
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3
81+
# Builds or restores the cached fixture app and installs it. Shared with any
82+
# other job that needs a controlled app to drive (see #320) — the cache is
83+
# repo-wide, so whichever workflow builds it first pays the ~22 minutes and
84+
# the rest get a hit.
85+
- name: Setup fixture app
86+
id: fixture-app
87+
uses: ./.github/actions/setup-fixture-app
9588
with:
96-
path: ${{ github.workspace }}/.tmp/fixture-app
97-
# Every input that can change the binary: app sources, native config,
98-
# dependency graph, the build step itself, and the toolchain.
99-
key: fixture-app-ios-${{ env.IOS_RUNTIME_VERSION }}-${{ steps.xcode.outputs.key }}-${{ hashFiles('examples/test-app/src/**', 'examples/test-app/app/**', 'examples/test-app/modules/**', 'examples/test-app/app.config.js', 'examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/workflows/conformance-differential.yml') }}
100-
101-
# The scenarios drive the real fixture app (com.callstack.agentdevicelab).
102-
# Release avoids needing a Metro server for the JS bundle; `expo run:ios`
103-
# prebuilds, builds, installs onto the booted simulator, and launches.
104-
- name: Build the fixture app
105-
if: steps.fixture-app-cache.outputs.cache-hit != 'true'
106-
run: |
107-
set -euo pipefail
108-
pnpm test-app:install
109-
pnpm --dir examples/test-app exec expo run:ios \
110-
--configuration Release \
111-
--device "iPhone 17 Pro" \
112-
--no-bundler
113-
# Stash the built bundle at a stable path so the cache has something
114-
# deterministic to keep (DerivedData paths are hashed per project).
115-
APP_PATH="$(find "$HOME/Library/Developer/Xcode/DerivedData" -type d -name '*.app' -path '*Release-iphonesimulator*' | head -1)"
116-
if [ -z "$APP_PATH" ]; then
117-
echo "::error::Built the fixture app but could not locate its .app bundle to cache."
118-
exit 1
119-
fi
120-
mkdir -p "${{ github.workspace }}/.tmp/fixture-app"
121-
cp -R "$APP_PATH" "${{ github.workspace }}/.tmp/fixture-app/"
122-
echo "cached $(basename "$APP_PATH")"
123-
124-
# On a hit the app was never built this run, so install it explicitly. (On a
125-
# miss `expo run:ios` already installed it; installing again is harmless.)
126-
- name: Install the fixture app
127-
run: |
128-
set -euo pipefail
129-
APP="$(find "${{ github.workspace }}/.tmp/fixture-app" -maxdepth 1 -type d -name '*.app' | head -1)"
130-
if [ -z "$APP" ]; then
131-
echo "::error::No fixture app bundle at .tmp/fixture-app (cache restored empty?)."
132-
exit 1
133-
fi
134-
xcrun simctl install booted "$APP"
89+
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
90+
device-name: iPhone 17 Pro
13591

136-
- name: Verify the fixture app is installed
92+
# The action guarantees *an* app is installed; this asserts it is the one
93+
# the scenarios actually target, so a fixture rename cannot leave the
94+
# differential driving the wrong app.
95+
- name: Verify the installed app is the one the scenarios target
13796
run: |
13897
set -euo pipefail
139-
# Single source of truth: the same constant the scenarios assert against.
140-
APP_ID=$(node --experimental-strip-types -e \
98+
EXPECTED=$(node --experimental-strip-types -e \
14199
"import('./scripts/maestro-conformance/differential/scenarios.ts').then(m => console.log(m.DIFFERENTIAL_APP_ID))")
142-
if ! xcrun simctl get_app_container booted "$APP_ID" >/dev/null 2>&1; then
143-
echo "::error::Fixture app $APP_ID is not installed on the booted simulator; layer-3 scenarios would fail vacuously."
100+
ACTUAL="${{ steps.fixture-app.outputs.app-id }}"
101+
echo "expected=$EXPECTED actual=$ACTUAL (cache-hit=${{ steps.fixture-app.outputs.cache-hit }})"
102+
if [ "$ACTUAL" != "$EXPECTED" ]; then
103+
echo "::error::Installed fixture app is $ACTUAL but the scenarios target $EXPECTED; they would fail vacuously."
144104
exit 1
145105
fi
146-
echo "Fixture app $APP_ID installed."
147106
148107
# Pin the Maestro CLI to the SAME version the oracle pins for layers 1-2,
149108
# read from pinned-upstream.json so the two can never drift apart. The

0 commit comments

Comments
 (0)