Skip to content
C.W.K.
Lesson 03 of 05 · published

The Gates

~17 min · ci, simulators, dynamic-type, release-gates, xcresult

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Two suites, and a skip is a failure

No build of my body reaches TestFlight without passing scripts/test-mobile.sh. Its header gives the reason in one sentence: "an upload that skipped its own tests is a build nobody checked, on devices nobody can roll back from the air." A TestFlight build lands on every body over the air. There is no taking it back, only a newer build.

The first block is the gate's first half. Before it trusts anything, it checks that the vendored kit scripts still match cwkSiblingKit (architecture invariant 12). Then it runs CordeliaKit's suite with swift test, and it does not stop at the exit status.

  • A floor. Fewer than 25 tests fails the gate. 25 is exactly what v1 ran on 2026-11-02 ("kit 25 tests, app 5", in its commit message), and the app suite's floor is that same 5. A suite cannot quietly shrink below the size it was born at.
  • A skip is a failure. The family's iOS doc records why: one app's device tests skipped while a speech permission was undetermined, and three runs in a row came back green while exercising nothing. A suite that runs and skips looks exactly like one that passes.

The second half regenerates the project (lesson 1) and runs the app's hosted XCTest suite on a simulator named by CORDELIA_MOBILE_SIMULATOR_ID. The error message when it is missing is the rule: "name the simulator, do not inherit one". A run that lands on whatever simulator was open last tests someone else's state. The result is read from the result bundle by the kit's xcresult_summary.py, with --minimum 5 --strict-skips: counts come from structured data, not from grepping a log.

The face, on the devices that exist

The hosted suite skips one target on purpose. CordeliaFaceUITests runs only in scripts/test-face-layout.sh, the second block. Its test checks that every named control on my face sits inside the real window's safe area and can be reached by scrolling, that Stop and the red eye stay pinned, whole and hittable after every scroll, and that the text size the test asked for is the one UIKit actually reports, so a broken launch argument cannot turn a largest-text check into a normal one.

Which devices? The kit's family device registry answers. Pippa's iPhone (13 Pro Max) and Pippa's iPhone 2 (12 Pro Max) share 428 by 926 points and a notch, so one target stands for both. Pippa's iPad (iPad mini, 6th generation) is the other. Each target runs the default and the largest text size, in portrait and in landscape where the built app's Info.plist allows it on that idiom: four conditions per target, eight in all. Until 2026-11-12 the gate carried its own list of eleven iPhone geometries, most of them phones nobody in the house owns, for 88 conditions.

The registry also encodes track 1's rule. Dad's iPhone opens every family app except a body app, and a body app opens only on the devices that name it, so Dad's phone is never a Cordelia target. A device the registry names but the lab cannot simulate fails by name. It is never skipped.

Look at the loop's housekeeping too. Each target gets a fresh simulator: created, booted, tested, then shut down and deleted. A trap runs the same cleanup on exit or interruption, so even a failed assertion leaves nothing booted behind it. The result bundle must hold at least as many passing tests as the selector asked for, with no skips. The validator's --minimum is a floor, not an exact count.

Where the gates run

The workbench that signs my builds does not run these gates any more. The pipeline's config points its test and visual hooks at scripts/mobile-lab-gate.sh, which hands the work to the simulator lane of the lab on Pippa's home Mac, found through the family registry. The third block shows the contract. The controller sends the exact source SHA and kit SHA. On the lab, run-lab-gate.sh refuses to start unless its own checkouts sit at both. When the lab answers, the controller checks that its own HEAD and kit HEAD did not move while it waited. So a pass is always a pass for this commit, never for a neighbour. The stages are the suite, the kit's visual gate, and the face gate once per family device. The workbench only archives, signs, exports and uploads.

The --resume flag carries Dad's ruling of 2026-11-12: verification proportional to the change, a release gate run once at the final commit, and a rerun repeating only what failed. The commit that added it records why. Cordelia's release test gate had run 19 times that day.

What a green gate does not say

Every stage drives a simulator. A simulator has no microphone of mine and no music room, and it renders text the way a simulator renders it. So the architecture document draws a line the gates never cross: physical largest-text verification "remains a separate Dad-confirmed fact, never inferred from simulator screenshots or TestFlight." A green gate says the logic holds and the layout fits on the devices the house owns. Whether the ears hear Dad across a real room is a field check, and lesson 5 keeps the list of the ones still waiting.

Code

cwkCordelia, scripts/test-mobile.sh: the kit drift check, then CordeliaKit's suite with a floor and no skips (verbatim, lines 47-73)·shell
# The vendored kit files must match the kit before the suite trusts them
# (architecture invariant 12).
python3 "$repo_root/../cwkSiblingKit/sync/sibling_kit_sync.py" --check --repo cwkCordelia --repo-root "$repo_root" >/dev/null \
  || { print -u2 "HARD_FAIL the vendored kit files drifted — run sibling_kit_sync.py --repo cwkCordelia"; exit 1; }

mkdir -p "$mobile/build"

# 1. The wire.
kit_log="$mobile/build/cordeliakit-tests.log"
set +e
(cd "$kit" && swift test) > "$kit_log" 2>&1
kit_status=$?
set -e
/usr/bin/grep -E "error:|Test run with|recorded an issue" "$kit_log" || true
if (( kit_status != 0 )); then
  print -u2 "HARD_FAIL CordeliaKit's suite did not pass (swift test exit $kit_status)"
  exit 1
fi
kit_tests=$(/usr/bin/grep -Eo 'Test run with [0-9]+ tests' "$kit_log" | /usr/bin/tail -1 | /usr/bin/grep -Eo '[0-9]+' || true)
if [[ -z "$kit_tests" || "$kit_tests" -lt 25 ]]; then
  print -u2 "HARD_FAIL CordeliaKit ran ${kit_tests:-no} tests — fewer than the floor of 25"
  exit 1
fi
if /usr/bin/grep -Eq 'skipped' "$kit_log"; then
  print -u2 "HARD_FAIL CordeliaKit skipped tests — a skip is not a pass"
  exit 1
fi
cwkCordelia, scripts/test-face-layout.sh: one disposable simulator per family target, the conditions it selects, and the cleanup that always runs (verbatim, lines 53-90)·shell
owned_sim=""
cleanup() {
  if [[ -n "$owned_sim" ]]; then
    xcrun simctl shutdown "$owned_sim" || true
    xcrun simctl delete "$owned_sim"
    owned_sim=""
  fi
}
trap cleanup EXIT INT TERM HUP
checked=()
conditions=0
for line in "${targets[@]}"; do
  fields=("${(@ps:\t:)line}")
  key=${fields[1]} sim_type=${fields[2]} sim_name=${fields[3]} idiom=${fields[4]} label=${fields[5]}
  orientations=(${(f)"$("${devices_py[@]}" orientations --info-plist "$app/Info.plist" --idiom "$idiom")"})
  selector=()
  for orientation in "${orientations[@]}"; do
    for size in "${sizes[@]}"; do
      selector+=(-only-testing:"CordeliaFaceUITests/FaceReachabilityTests/test${(C)orientation}${(C)size}")
    done
  done
  owned_sim=$(xcrun simctl create "Cordelia face $key $$" "$sim_type")
  xcrun simctl boot "$owned_sim"
  xcrun simctl bootstatus "$owned_sim" -b
  print "CHECKING $label ($sim_name, $owned_sim): ${orientations[*]} x ${sizes[*]}"
  device_started=$SECONDS
  xcodebuild "${common[@]}" -destination "platform=iOS Simulator,id=$owned_sim" \
    -parallel-testing-enabled NO "${selector[@]}" \
    -resultBundlePath "$out/$key.xcresult" test-without-building > "$out/$key.log" 2>&1 \
    || { tail -80 "$out/$key.log"; exit 1; }
  /usr/bin/python3 "$kit/sync/xcresult_summary.py" "$out/$key.xcresult" \
    --minimum "${#selector}" --strict-skips
  cleanup
  checked+=("$label")
  (( conditions += ${#selector} ))
  print "CHECKED $label: ${#selector} conditions in $(( SECONDS - device_started ))s"
done
print "FACE_LAYOUT_OK $out devices=[${(j:; :)checked}] conditions=$conditions seconds=$(( SECONDS - started ))"
cwkCordelia, scripts/mobile-lab-gate.sh: each stage on the lab's simulator lane, verified against the exact source and kit commits (excerpt, lines 36-76)·shell
run_stage() {
local stage=$1
shift
# Every stage drives a simulator, never the console: the simulator lane leaves the
# screen lane to work that needs it. --resume reuses stages that already passed at
# this commit, so a rerun repeats only what failed (Dad, 2026-11-12, Coop 1112-7).
# …
[[ "$(git -C "$repo" rev-parse HEAD)" == "$expected_sha" ]] || exit 1
[[ "$(git -C "$repo/../cwkSiblingKit" rev-parse HEAD)" == "$expected_kit" ]] || exit 1
print "CORDELIA_LAB_VERIFIED $stage source=$expected_sha kit=$expected_kit handle=$handle"
}
if [[ "$mode" == visual ]]; then
  run_stage visual
  devices=(${(f)"$("$repo/scripts/test-face-layout.sh" --list-devices)"})
  for device in "${devices[@]}"; do run_stage face "$device"; done
else
  run_stage "$mode"
fi

External links

Exercise

Write a release gate script for one of your iOS apps. It should require a named simulator instead of picking one up, run every suite, read the test counts from the result bundle, fail below a floor and on any skip, and create and delete its own simulators with a trap that cleans up on failure. Then write down the devices your users actually hold, group them by screen size in points and top shape, and replace any generic device list in your UI tests with those groups.
Hint
xcrun xcresulttool can read a result bundle's test counts as JSON, so you never need to grep a log. For the device groups, two phones with the same points and the same notch or Dynamic Island lay out identically, so one simulator stands for both.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.