I added a preflight gate to an iOS + watchOS app build this week because the build script had a small but annoying property: it mutated the repo before it proved the repo was safe to build.
The script stamps CFBundleVersion with a timestamp before generating the Xcode project and archiving for TestFlight. That is useful because every upload gets a monotonically increasing build number.
It is also exactly the wrong thing to do before quality checks.
If formatting or tests fail after that stamp, the working tree is now dirty for a reason unrelated to the change I was trying to ship. Small thing, but this is how build scripts become suspicious. You run them, they fail, then you have to separate your actual diff from the build system's leftovers.
So I moved the build into a fail-fast shape:
echo "==> Running preflight checks..."
"$PROJECT_DIR/scripts/preflight.sh"
BUILD_NUMBER=$(date +%Y%m%d%H%M)
sed -i '' "s/CFBundleVersion: \"[^\"]*\"/CFBundleVersion: \"$BUILD_NUMBER\"/" "$PROJECT_DIR/project.yml"
The important bit is ordering. preflight.sh runs before the build number is touched.
The gate currently has four checks:
# 1. no local surprises
git -C "$PROJECT_DIR" status --porcelain
# 2. formatting is enforced, not auto-mutated
swiftformat --lint \
"$PROJECT_DIR/SessionzApp/" \
"$PROJECT_DIR/SessionzKit/Sources/" \
"$PROJECT_DIR/SessionzKit/Tests/" \
"$PROJECT_DIR/SessionzWatch/" \
--config "$PROJECT_DIR/.swiftformat"
# 3. local Swift package tests
swift test --package-path "$PROJECT_DIR/SessionzKit" --parallel
# 4. backend edge-function unit tests
deno test "$PROJECT_DIR/supabase/functions/_lib/"
That last line is the one I care about most.
The app has a Claude-backed plan generation flow. The mobile app sends goals, equipment, available weights, and a small training context to a backend function. The backend validates auth/subscription state, calls Claude, then returns structured JSON that becomes local SwiftData models.
I do not want the only tests for that boundary to live in an app simulator.
So I pulled the pure logic out of the Edge Function handlers into _lib/ modules and tested that directly with Deno:
// subscription.ts
export function isActiveStatus(status: string | null): boolean {
return status === "active" || status === "trialing";
}
export function validateProductId(productId: unknown): string | null {
return typeof productId === "string" && productId.trim().length > 0
? productId
: null;
}
Same idea on the Swift side. The AI coach needs compact summaries of recent training, not an unbounded dump of every logged set. PerformanceSummary condenses the last 42 days into short lines like:
Dumbbell Floor Press: best 20 kg × 8, trained 3×
Push-up: best 20 reps (bodyweight), trained 2×
That now has regression coverage for the edge cases that tend to rot quietly: bodyweight vs weighted sets, zero-weight entries, sorting by training frequency, limiting output size, and ignoring sets outside the six-week window.
The result is not fancy CI. It is just a local quality gate that refuses to make a TestFlight archive unless the repo is clean, formatted, and both sides of the AI boundary pass tests.
This is the kind of infrastructure I like for AI features: boring checks around the non-boring part.
The model can be probabilistic. The system around it should not be.
Source: Recent Sessionz commits: scripts/preflight.sh, TestFlight build integration, Swift regression tests for plan/performance logic, and Deno tests for backend Edge Function helpers.
Tags: ai, swift, testing, devops
Status: published
Top comments (0)