-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpushback
More file actions
executable file
·686 lines (608 loc) · 29.7 KB
/
Copy pathpushback
File metadata and controls
executable file
·686 lines (608 loc) · 29.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
#!/usr/bin/env bash
#
# pushback — ship an iOS app to TestFlight in one command, from your laptop.
#
# It does the whole release in a single pass: verify the build, run the QA
# gate, (optionally) merge the PR, bump the version, archive, upload to
# App Store Connect, then commit + push the version bump.
#
# Two modes, auto-detected:
#
# PR mode An open PR exists for the current branch (or you passed a
# PR number). pushback verifies the build on the PR branch,
# runs QA, squash-merges, then ships from a freshly-pulled
# main. A broken iOS build can never land in main.
#
# From-main mode No open PR (e.g. you're on `main` with everything already
# merged, or on a local branch). pushback ships the current
# branch's *current state* — including uncommitted working-
# tree changes, if you choose to include them. Nothing is
# merged; the version-bump commit lands on the current branch.
#
# The one invariant that matters most: once the upload to App Store Connect
# succeeds, the bumped build number is BURNED. pushback will never revert a
# bump after a successful upload — doing so would make the next run reuse the
# number and fail ASC validation. See cleanup_on_failure().
#
# pushback is configured per-project via a sourced shell file (`.pushbackrc`); see
# `.pushbackrc.example`. It has no YAML/runtime dependencies beyond the toolchain
# it drives (git, gh, xcodegen, xcodebuild) plus two optional niceties
# (xcbeautify, maestro) that it gracefully skips when absent.
#
# License: MIT. See LICENSE.
set -euo pipefail
PUSHBACK_VERSION="0.1.0"
# ── Argument parsing ───────────────────────────────────────────────
ARG_PR=""
FORCE_FROM_MAIN=0
SKIP_QA="${SKIP_PRE_SHIP_QA:-0}"
ASSUME_YES=0
DRY_RUN=0
CONFIG_PATH=""
print_help() {
cat <<'EOF'
pushback — ship an iOS app to TestFlight in one command.
USAGE
pushback [options] [PR_NUMBER]
MODES (auto-detected; override with --from-main)
PR mode An open PR exists for the current branch (or PR_NUMBER given):
verify build → QA gate → squash-merge → ship from main.
From-main No open PR (e.g. you're on main, already merged): ship the
current branch's current state, optionally including
uncommitted working-tree changes.
OPTIONS
--from-main, --no-merge Force from-main mode (skip PR detection + merge).
--skip-qa Skip the unit-test + Maestro gate.
-y, --yes Don't prompt. Auto-confirm; a dirty tree is
included wholesale (use with care).
--dry-run Run everything reversible (preflight, mode select,
dirty prompt, version bump) but STUB the archive,
upload, merge, and push. Reverts the bump at the
end. Safe to run anytime.
--config <path> Path to the .pushbackrc config file (required).
-V, --version Print the version and exit.
-h, --help Show this help.
ENVIRONMENT
SKIP_PRE_SHIP_QA=1 Same as --skip-qa.
NO_COLOR=1 Disable ANSI color (https://no-color.org/).
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--from-main|--no-merge) FORCE_FROM_MAIN=1 ;;
--skip-qa) SKIP_QA=1 ;;
-y|--yes) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--config) CONFIG_PATH="${2:-}"; shift ;;
--config=*) CONFIG_PATH="${1#*=}" ;;
-V|--version) echo "pushback $PUSHBACK_VERSION"; exit 0 ;;
-h|--help) print_help; exit 0 ;;
-*) echo "pushback: unknown option '$1'" >&2; print_help >&2; exit 2 ;;
*)
if [ -n "$ARG_PR" ]; then
echo "pushback: unexpected extra argument '$1'" >&2; exit 2
fi
ARG_PR="$1"
;;
esac
shift
done
# ── Load config ────────────────────────────────────────────────────
# The config is a plain shell file that sets PUSHBACK_* variables. Sourcing
# it (rather than parsing YAML) keeps pushback dependency-free and robust.
if [ -z "$CONFIG_PATH" ]; then
# Fall back to a .pushbackrc next to the invoking script, then CWD.
if [ -f "$(dirname "$0")/.pushbackrc" ]; then CONFIG_PATH="$(dirname "$0")/.pushbackrc"
elif [ -f ".pushbackrc" ]; then CONFIG_PATH=".pushbackrc"
else echo "pushback: no --config given and no .pushbackrc found." >&2; exit 2; fi
fi
[ -f "$CONFIG_PATH" ] || { echo "pushback: config not found at '$CONFIG_PATH'" >&2; exit 2; }
# shellcheck disable=SC1090
. "$CONFIG_PATH"
# Defaults — a minimal config need only set PUSHBACK_SCHEME.
: "${PUSHBACK_PRODUCT_NAME:=app}"
: "${PUSHBACK_APP_DIR:=ios}"
: "${PUSHBACK_SCHEME:?config must set PUSHBACK_SCHEME}"
: "${PUSHBACK_PROJECT:=${PUSHBACK_SCHEME}.xcodeproj}"
: "${PUSHBACK_PROJECT_YML:=project.yml}"
: "${PUSHBACK_EXPORT_OPTIONS:=ExportOptions.plist}"
: "${PUSHBACK_BUMP_SCRIPT:=./bump-version.sh}"
: "${PUSHBACK_BUMP_LEVEL:=patch}"
: "${PUSHBACK_TEST_TARGET:=${PUSHBACK_SCHEME}Tests}"
: "${PUSHBACK_MAESTRO_DIR:=maestro}"
: "${PUSHBACK_SIM_DEVICE:=iPhone 17}"
: "${PUSHBACK_APP_BUNDLE:=${PUSHBACK_SCHEME}.app}"
: "${PUSHBACK_ASC_KEY_ID_VAR:=APPSTORE_CONNECT_API_KEY_ID}"
: "${PUSHBACK_ASC_ISSUER_ID_VAR:=APPSTORE_CONNECT_API_ISSUER_ID}"
: "${PUSHBACK_ASC_ENV_FILE:=.env.local}"
: "${PUSHBACK_ASC_ENV_PREFIX:=APPSTORE_CONNECT_}"
: "${PUSHBACK_ASC_KEY_DIR:=$HOME/.private_keys}"
: "${PUSHBACK_COMMIT_PATHS:=${PUSHBACK_APP_DIR}/${PUSHBACK_PROJECT_YML} ${PUSHBACK_APP_DIR}/${PUSHBACK_PROJECT}/}"
# ── Resolve paths ──────────────────────────────────────────────────
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" \
|| { echo "pushback: not inside a git repository." >&2; exit 1; }
APP_DIR="$REPO_ROOT/$PUSHBACK_APP_DIR"
PROJECT_YML="$APP_DIR/$PUSHBACK_PROJECT_YML"
PROJECT="$APP_DIR/$PUSHBACK_PROJECT"
EXPORT_OPTIONS="$APP_DIR/$PUSHBACK_EXPORT_OPTIONS"
ARCHIVE_PATH="$APP_DIR/build/${PUSHBACK_SCHEME}.xcarchive"
EXPORT_DIR="$APP_DIR/build/export"
VERIFY_BUILD_DIR="$APP_DIR/build/verify"
# ── Pipeline state (read by cleanup_on_failure) ────────────────────
# A bump that's been uploaded must NEVER be reverted (build number burned).
VERSION_BUMPED=0
UPLOAD_SUCCEEDED=0
VERSION_BUMP_COMMITTED=0
PUSHED=0
STASHED=0
TARGET_BRANCH=""
# ── App Store Connect API key ──────────────────────────────────────
# Source the project's env file if the ASC vars aren't already exported.
if [ -z "${!PUSHBACK_ASC_KEY_ID_VAR:-}" ] || [ -z "${!PUSHBACK_ASC_ISSUER_ID_VAR:-}" ]; then
ENV_FILE="$REPO_ROOT/$PUSHBACK_ASC_ENV_FILE"
if [ -f "$ENV_FILE" ]; then
eval "$(grep "^${PUSHBACK_ASC_ENV_PREFIX}" "$ENV_FILE" | sed 's/^/export /')"
fi
fi
API_KEY_ID="${!PUSHBACK_ASC_KEY_ID_VAR:?$PUSHBACK_ASC_KEY_ID_VAR not set — add to $PUSHBACK_ASC_ENV_FILE or export it}"
API_ISSUER_ID="${!PUSHBACK_ASC_ISSUER_ID_VAR:?$PUSHBACK_ASC_ISSUER_ID_VAR not set — add to $PUSHBACK_ASC_ENV_FILE or export it}"
# ── Visual helpers ─────────────────────────────────────────────────
# Honor NO_COLOR (https://no-color.org/) and skip ANSI on a non-TTY stdout.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
BOLD=$'\033[1m'; DIM=$'\033[2m'; RESET=$'\033[0m'
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
BLUE=$'\033[0;34m'; CYAN=$'\033[0;36m'
else
BOLD=''; DIM=''; RESET=''
RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''
fi
SHIP_START=$SECONDS
STEP_START=0
STEP_NUM=0
TOTAL_STEPS=0
# xcbeautify formats xcodebuild output. If absent we fall back to -quiet
# plus a spinner so a multi-minute archive doesn't look frozen.
USE_XCB=0
command -v xcbeautify >/dev/null 2>&1 && USE_XCB=1
hr() { printf '%s\n' "${DIM}────────────────────────────────────────────────────────────${RESET}"; }
info() { printf " ${CYAN}▸${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}▸${RESET} %s\n" "$1"; }
note() { printf " ${DIM}%s${RESET}\n" "$1"; }
fail() { printf " ${RED}✗${RESET} %s\n" "$1" >&2; exit 1; }
fmt_duration() {
local s=$1
if [ "$s" -lt 60 ]; then printf "%ds" "$s"
else printf "%dm%02ds" $((s / 60)) $((s % 60)); fi
}
step() {
STEP_NUM=$((STEP_NUM + 1))
STEP_START=$SECONDS
echo
printf " ${BOLD}${BLUE}[%d/%d]${RESET} ${BOLD}%s${RESET}\n" "$STEP_NUM" "$TOTAL_STEPS" "$1"
echo
}
step_done() {
printf " ${GREEN}✓${RESET} ${DIM}done in %s${RESET}\n" "$(fmt_duration $((SECONDS - STEP_START)))"
}
step_skip() {
printf " ${YELLOW}⊘${RESET} ${DIM}skipped — %s${RESET}\n" "$1"
}
# Run a quiet/silent command with a braille spinner so long steps show
# progress. Output is captured and, on failure, printed in full so an error
# is NEVER masked. Preserves the command's exit code. Falls back to a plain
# run when stdout isn't a TTY.
spinner_run() {
local label=$1; shift
if [ ! -t 1 ]; then "$@"; return $?; fi
local log; log="$(mktemp -t pushback)"
"$@" >"$log" 2>&1 &
local pid=$! rc=0 i=0
# Indexed array (not a string slice): bash 3.2 substring-slices by BYTE, which
# would split these 3-byte braille glyphs into mojibake. Array elements stay whole.
local frames=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
local n=${#frames[@]}
while kill -0 "$pid" 2>/dev/null; do
printf "\r ${CYAN}%s${RESET} ${DIM}%s${RESET}" "${frames[$((i % n))]}" "$label"
i=$((i + 1))
sleep 0.1
done
if wait "$pid"; then rc=0; else rc=$?; fi
printf "\r\033[K"
if [ "$rc" -ne 0 ]; then sed 's/^/ /' "$log" >&2; fi
rm -f "$log"
return "$rc"
}
# Run xcodebuild with friendly output: xcbeautify if available (streams,
# so it doubles as live progress), else -quiet behind a spinner. In dry-run
# the build is stubbed. Exit code propagates via `set -o pipefail`.
run_xcodebuild() {
if [ "$DRY_RUN" = "1" ]; then
printf " ${DIM}[dry-run] xcodebuild %s${RESET}\n" "$1"; return 0
fi
if [ "$USE_XCB" = "1" ]; then
xcodebuild "$@" | xcbeautify --renderer terminal --quiet
else
spinner_run "compiling…" xcodebuild "$@" -quiet
fi
}
confirm() {
# confirm "<prompt>" — returns 0 on yes. Honors --yes; on a non-TTY with
# no --yes it refuses (so an unattended run can't hang on read).
if [ "$ASSUME_YES" = "1" ]; then return 0; fi
if [ ! -t 0 ]; then
fail "Needs confirmation but stdin isn't a TTY. Re-run with --yes to proceed non-interactively."
fi
local reply=""
printf " ${BOLD}%s${RESET} ${DIM}[y/N]${RESET} " "$1"
read -r reply || true
case "$reply" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
}
# Restore a stash made for "ship HEAD only", on both success and failure.
# Never drops the stash on conflict — the user's work stays recoverable.
restore_stash_if_any() {
if [ "$STASHED" = "1" ]; then
STASHED=0
if git -C "$REPO_ROOT" stash pop >/dev/null 2>&1; then
note "restored your stashed working-tree changes"
else
warn "Couldn't auto-restore your stashed changes (likely a conflict)."
warn "They're safe — recover with: git -C \"$REPO_ROOT\" stash list / stash pop"
fi
fi
}
cleanup_on_failure() {
local rc=$?
if [ "$rc" -ne 0 ]; then
if [ "$VERSION_BUMPED" = "1" ] && [ "$VERSION_BUMP_COMMITTED" = "0" ]; then
if [ "$UPLOAD_SUCCEEDED" = "1" ]; then
# Upload landed in ASC but the commit failed. The build number
# is burned — reverting locally would make the next run reuse
# it and fail validation. Leave it; tell the user to commit.
printf '\n%s\n' " ${YELLOW}▸${RESET} Upload succeeded but the commit failed." >&2
printf '%s\n' " ${YELLOW}▸${RESET} Version bump LEFT IN PLACE — do NOT revert. Commit + push manually:" >&2
printf " git -C %q add %s && git -C %q commit && git -C %q push origin %s\n" \
"$REPO_ROOT" "$PUSHBACK_COMMIT_PATHS" "$REPO_ROOT" "$REPO_ROOT" "${TARGET_BRANCH:-main}" >&2
else
printf '\n%s\n' " ${YELLOW}▸${RESET} Reverting uncommitted version bump…" >&2
# shellcheck disable=SC2086
(cd "$REPO_ROOT" && git checkout -- $PUSHBACK_COMMIT_PATHS 2>/dev/null) || true
fi
elif [ "$VERSION_BUMP_COMMITTED" = "1" ] && [ "$PUSHED" = "0" ]; then
# Bump committed locally but push failed — nothing to revert, just push.
printf '\n%s\n' " ${YELLOW}▸${RESET} Bump committed locally but the push failed. Push manually:" >&2
printf " git -C %q push origin %s\n" "$REPO_ROOT" "${TARGET_BRANCH:-main}" >&2
fi
restore_stash_if_any
echo >&2
hr >&2
printf " ${RED}${BOLD}✗ pushback failed${RESET} ${DIM}(exit %d, after %s)${RESET}\n" \
"$rc" "$(fmt_duration $((SECONDS - SHIP_START)))" >&2
hr >&2
echo >&2
fi
exit "$rc"
}
trap cleanup_on_failure EXIT
# ── Version helpers ────────────────────────────────────────────────
read_version() { grep 'MARKETING_VERSION:' "$PROJECT_YML" | sed 's/.*"\(.*\)"/\1/'; }
read_build() { grep 'CURRENT_PROJECT_VERSION:' "$PROJECT_YML" | awk '{print $2}'; }
# Predict what bump-version.sh will produce, for the pre-ship summary only.
predict_next() {
local v b; v="$(read_version)"; b="$(read_build)"
local major minor patch
IFS='.' read -r major minor patch <<<"$v"
case "$PUSHBACK_BUMP_LEVEL" in
patch) patch=$((patch + 1)) ;;
minor) minor=$((minor + 1)); patch=0 ;;
major) major=$((major + 1)); minor=0; patch=0 ;;
esac
printf '%s.%s.%s (%s)' "$major" "$minor" "$patch" "$((b + 1))"
}
# ── Preflight ──────────────────────────────────────────────────────
command -v git >/dev/null || fail "git not found."
command -v gh >/dev/null || fail "gh CLI not found. Install: brew install gh"
command -v xcodegen >/dev/null || fail "xcodegen not found. Install: brew install xcodegen"
command -v xcodebuild >/dev/null || fail "xcodebuild not found. Fix: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"
XCODE_PATH="$(xcode-select -p 2>/dev/null || true)"
case "$XCODE_PATH" in
*/CommandLineTools*)
fail "xcode-select points at CommandLineTools, not Xcode.app.
Fix: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" ;;
esac
API_KEY_FILE="$PUSHBACK_ASC_KEY_DIR/AuthKey_${API_KEY_ID}.p8"
[ -f "$API_KEY_FILE" ] || fail "App Store Connect API key not found at $API_KEY_FILE
Download from App Store Connect → Users → Keys and place it there."
[ -f "$PROJECT_YML" ] || fail "project.yml not found at $PROJECT_YML (check PUSHBACK_APP_DIR in $CONFIG_PATH)."
# ── Mode detection ─────────────────────────────────────────────────
CURRENT_BRANCH="$(git -C "$REPO_ROOT" branch --show-current)"
MODE=""
PR_NUMBER=""
if [ -n "$ARG_PR" ]; then
MODE="pr"; PR_NUMBER="$ARG_PR"
elif [ "$FORCE_FROM_MAIN" = "1" ]; then
MODE="main"
elif [ "$CURRENT_BRANCH" != "main" ] && \
PR_NUMBER="$(gh pr view "$CURRENT_BRANCH" --json number,state --jq 'select(.state=="OPEN").number' 2>/dev/null)" && \
[ -n "$PR_NUMBER" ]; then
MODE="pr"
else
MODE="main"
fi
# ── PR mode: HEAD-match guard + clean-tree requirement ─────────────
if [ "$MODE" = "pr" ]; then
TARGET_BRANCH="main" # PR squash-merges into main; we ship from there
# Shipping a PR means shipping the PR's code — a dirty local tree is junk
# here and would also block the post-merge `git checkout main`.
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
fail "Working tree is dirty, but PR mode ships the PR's code. Commit/stash first, or use --from-main to ship local changes."
fi
PR_HEAD_SHA="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid' 2>/dev/null)" \
|| fail "Could not fetch head SHA for PR #$PR_NUMBER."
LOCAL_HEAD_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
if [ "$PR_HEAD_SHA" != "$LOCAL_HEAD_SHA" ]; then
fail "Local HEAD ($LOCAL_HEAD_SHA) ≠ PR #$PR_NUMBER head ($PR_HEAD_SHA).
Sync first: gh pr checkout $PR_NUMBER"
fi
TOTAL_STEPS=8
else
TARGET_BRANCH="$CURRENT_BRANCH"
TOTAL_STEPS=6
fi
# ── Banner ─────────────────────────────────────────────────────────
echo
printf '%s' " ${BOLD}${BLUE}✈ Ship to TestFlight${RESET}"
[ "$DRY_RUN" = "1" ] && printf '%s' " ${YELLOW}${BOLD}[DRY RUN]${RESET}"
echo
if [ "$MODE" = "pr" ]; then
printf " ${DIM}%s · ios · pr #%s → main${RESET}\n" "$PUSHBACK_PRODUCT_NAME" "$PR_NUMBER"
else
printf " ${DIM}%s · ios · %s → testflight${RESET}\n" "$PUSHBACK_PRODUCT_NAME" "$TARGET_BRANCH"
fi
printf " ${DIM}version: %s (%s) → %s${RESET}\n" "$(read_version)" "$(read_build)" "$(predict_next)"
[ "$USE_XCB" = "0" ] && printf '%s\n' " ${DIM}tip: brew install xcbeautify for nicer build output${RESET}"
echo
hr
# ── From-main: dirty working tree handling ─────────────────────────
# The build reads the working tree as-is, so "include" literally ships your
# uncommitted changes. We always show exactly what that is before doing it.
INCLUDE_DIRTY=0
DIRTY_MENU_SHOWN=0 # set when the interactive menu is shown; that choice IS the confirm
if [ "$MODE" = "main" ] && [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
echo
warn "Working tree has uncommitted changes:"
echo
git -C "$REPO_ROOT" -c color.status=always status --short | sed 's/^/ /'
echo
n_tracked="$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no | grep -c . || true)"
n_untracked="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard | grep -c . || true)"
note "$n_tracked tracked change(s), $n_untracked new untracked file(s)"
echo
choice=""
if [ "$ASSUME_YES" = "1" ]; then
choice="i" # --yes ⇒ include everything
elif [ ! -t 0 ]; then
fail "Dirty tree needs a decision but stdin isn't a TTY. Re-run with --yes (include all) or commit/stash first."
else
DIRTY_MENU_SHOWN=1
printf '%s\n' " ${BOLD}Ship these changes?${RESET}"
printf '%s\n' " ${BOLD}i${RESET} ${DIM}include them in the version-bump commit and ship${RESET}"
printf '%s\n' " ${BOLD}s${RESET} ${DIM}stash them, ship committed HEAD only, restore after${RESET}"
printf '%s\n' " ${BOLD}d${RESET} ${DIM}show full diff${RESET}"
printf '%s\n' " ${BOLD}a${RESET} ${DIM}abort${RESET}"
while :; do
printf '%s' " ${BOLD}choose [i/s/d/a]:${RESET} "
read -r choice || true
case "$choice" in
d|D)
echo
git -C "$REPO_ROOT" -c color.diff=always diff HEAD | sed 's/^/ /'
dirty_untracked="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)"
if [ -n "$dirty_untracked" ]; then
printf '%s\n' " ${DIM}new untracked files (will be added):${RESET}"
printf '%s\n' "$dirty_untracked" | sed 's/^/ + /'
fi
echo ;;
i|I|s|S|a|A) break ;;
*) warn "enter i, s, d, or a" ;;
esac
done
fi
case "$choice" in
i|I) INCLUDE_DIRTY=1; info "Including uncommitted changes in this ship." ;;
s|S)
info "Stashing uncommitted changes — shipping committed HEAD only."
git -C "$REPO_ROOT" stash push --include-untracked --message "pushback: pre-ship stash" >/dev/null
STASHED=1 ;;
a|A) fail "Aborted — nothing shipped." ;;
esac
fi
# ── Behind-upstream check (informational) ──────────────────────────
# A push after a successful upload that gets rejected is recoverable (the
# trap tells you to pull+push), but better to know now.
if git -C "$REPO_ROOT" rev-parse --abbrev-ref "@{upstream}" >/dev/null 2>&1; then
git -C "$REPO_ROOT" fetch --quiet origin "$TARGET_BRANCH" 2>/dev/null || true
BEHIND="$(git -C "$REPO_ROOT" rev-list --count "HEAD..origin/${TARGET_BRANCH}" 2>/dev/null || echo 0)"
if [ "${BEHIND:-0}" -gt 0 ]; then
warn "Local $TARGET_BRANCH is $BEHIND commit(s) behind origin/$TARGET_BRANCH — the final push may need a manual pull."
fi
fi
# ── Pre-ship confirmation ──────────────────────────────────────────
echo
if [ "$MODE" = "pr" ]; then
printf " About to ${BOLD}merge PR #%s${RESET} and ship ${BOLD}%s${RESET} to TestFlight.\n" "$PR_NUMBER" "$(predict_next)"
else
if [ "$INCLUDE_DIRTY" = "1" ]; then
printf " About to ship ${BOLD}%s${RESET} (incl. uncommitted changes) to TestFlight and push %s.\n" "$(predict_next)" "$TARGET_BRANCH"
else
printf " About to ship ${BOLD}%s${RESET} from %s to TestFlight and push %s.\n" "$(predict_next)" "$TARGET_BRANCH" "$TARGET_BRANCH"
fi
fi
# The interactive dirty menu already served as the confirmation; don't double-prompt.
if [ "$DIRTY_MENU_SHOWN" != "1" ]; then
confirm "Continue?" || fail "Aborted — nothing shipped."
fi
# ── Step: Verify build ─────────────────────────────────────────────
# Compile for the simulator (no signing) to fail fast on Swift errors
# BEFORE the merge (PR mode) or the version bump (from-main mode).
step "Verify build"
(cd "$APP_DIR" && xcodegen generate >/dev/null)
rm -rf "$VERIFY_BUILD_DIR"
run_xcodebuild build \
-project "$PROJECT" \
-scheme "$PUSHBACK_SCHEME" \
-destination "generic/platform=iOS Simulator" \
-derivedDataPath "$VERIFY_BUILD_DIR" \
CODE_SIGNING_ALLOWED=NO \
|| fail "iOS build failed. Fix it before shipping — nothing has been merged or bumped."
rm -rf "$VERIFY_BUILD_DIR"
step_done
# ── Step: QA gate (unit + Maestro) ─────────────────────────────────
step "QA suite (unit + Maestro)"
if [ "$SKIP_QA" = "1" ]; then
step_skip "--skip-qa"
else
info "Unit + snapshot tests"
run_xcodebuild test \
-project "$PROJECT" \
-scheme "$PUSHBACK_SCHEME" \
-destination "platform=iOS Simulator,name=${PUSHBACK_SIM_DEVICE},OS=latest" \
-derivedDataPath "$VERIFY_BUILD_DIR" \
-only-testing:"$PUSHBACK_TEST_TARGET" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
|| fail "iOS test suite failed. Fix tests before shipping (or --skip-qa)."
if [ "$DRY_RUN" = "0" ] && command -v maestro >/dev/null 2>&1; then
info "Maestro smoke flows"
mkdir -p "$APP_DIR/build"
xcrun simctl boot "$PUSHBACK_SIM_DEVICE" 2>/dev/null || true
APP_PATH="$(find "$VERIFY_BUILD_DIR" -name "$PUSHBACK_APP_BUNDLE" -type d | head -1)"
if [ -n "$APP_PATH" ]; then
xcrun simctl install booted "$APP_PATH"
maestro test "$APP_DIR/$PUSHBACK_MAESTRO_DIR/" \
--output "$APP_DIR/build/maestro-output.log" \
|| fail "Maestro smoke failed. Review $APP_DIR/build/maestro-output.log."
else
warn "Could not locate $PUSHBACK_APP_BUNDLE — skipping Maestro (tests passed; ship proceeds)."
fi
elif [ "$DRY_RUN" = "0" ]; then
warn "maestro not installed — skipping smoke flows. Install: curl -fsSL https://get.maestro.mobile.dev | bash"
fi
rm -rf "$VERIFY_BUILD_DIR"
step_done
fi
# ── PR mode only: restore xcodegen mutations, merge, sync main ─────
if [ "$MODE" = "pr" ]; then
# xcodegen may have touched the committed .xcodeproj during verify. Restore
# it so the tree is clean before merge — otherwise the post-merge checkout
# would strand the pipeline.
# shellcheck disable=SC2086
(cd "$REPO_ROOT" && git checkout -- $PUSHBACK_COMMIT_PATHS 2>/dev/null) || true
step "Merge PR #$PR_NUMBER (squash)"
if [ "$DRY_RUN" = "1" ]; then
note "[dry-run] would: gh pr merge $PR_NUMBER --squash --delete-branch"
else
gh pr merge "$PR_NUMBER" --squash --delete-branch \
|| fail "Failed to merge PR #$PR_NUMBER. Check if it's mergeable."
fi
step_done
step "Sync main"
if [ "$DRY_RUN" = "1" ]; then
note "[dry-run] would: git checkout main && git pull"
else
git -C "$REPO_ROOT" checkout main >/dev/null 2>&1
git -C "$REPO_ROOT" pull --quiet
fi
step_done
fi
# ── Step: Bump version ─────────────────────────────────────────────
step "Bump version"
(cd "$APP_DIR" && xcodegen generate >/dev/null)
(cd "$APP_DIR" && "$PUSHBACK_BUMP_SCRIPT" "$PUSHBACK_BUMP_LEVEL")
VERSION_BUMPED=1
(cd "$APP_DIR" && xcodegen generate >/dev/null)
NEW_VERSION="$(read_version)"
NEW_BUILD="$(read_build)"
info "→ $NEW_VERSION ($NEW_BUILD)"
step_done
# ── Step: Archive ──────────────────────────────────────────────────
step "Archive release build"
rm -rf "$ARCHIVE_PATH" "$EXPORT_DIR"
run_xcodebuild archive \
-project "$PROJECT" \
-scheme "$PUSHBACK_SCHEME" \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=iOS" \
-allowProvisioningUpdates \
-authenticationKeyPath "$API_KEY_FILE" \
-authenticationKeyID "$API_KEY_ID" \
-authenticationKeyIssuerID "$API_ISSUER_ID" \
|| fail "Archive failed."
step_done
# ── Step: Export + upload to TestFlight ────────────────────────────
# ExportOptions.plist sets destination=upload, so this single xcodebuild
# step packages AND uploads. Do NOT add a follow-up `altool --upload-app` —
# destination=upload leaves no IPA on disk, so altool would error while the
# upload has already succeeded.
step "Upload to TestFlight"
run_xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-exportPath "$EXPORT_DIR" \
-allowProvisioningUpdates \
-authenticationKeyPath "$API_KEY_FILE" \
-authenticationKeyID "$API_KEY_ID" \
-authenticationKeyIssuerID "$API_ISSUER_ID" \
|| fail "Export/upload failed."
[ "$DRY_RUN" = "0" ] && UPLOAD_SUCCEEDED=1
step_done
# ── Step: Commit + push version bump ───────────────────────────────
# In include mode, fold the user's uncommitted changes into the same commit.
step "Commit + push version bump"
if [ "$DRY_RUN" = "1" ]; then
# Stage nothing in a rehearsal — just undo the bump so the tree is clean.
note "[dry-run] would commit $NEW_VERSION ($NEW_BUILD) and push origin $TARGET_BRANCH"
[ "$INCLUDE_DIRTY" = "1" ] && note "[dry-run] would include your uncommitted changes in that commit"
note "[dry-run] reverting the version bump to leave a clean tree"
# shellcheck disable=SC2086
(cd "$REPO_ROOT" && git checkout -- $PUSHBACK_COMMIT_PATHS 2>/dev/null) || true
VERSION_BUMPED=0
restore_stash_if_any
step_done
else
if [ "$INCLUDE_DIRTY" = "1" ]; then
# Fold the user's uncommitted changes into the same bump commit.
git -C "$REPO_ROOT" add -A
COMMIT_MSG="chore: bump iOS to $NEW_VERSION ($NEW_BUILD)
Includes uncommitted working-tree changes present at ship time."
else
# shellcheck disable=SC2086
git -C "$REPO_ROOT" add $PUSHBACK_COMMIT_PATHS
COMMIT_MSG="chore: bump iOS to $NEW_VERSION ($NEW_BUILD)"
fi
git -C "$REPO_ROOT" commit --quiet -m "$COMMIT_MSG"
VERSION_BUMP_COMMITTED=1
git -C "$REPO_ROOT" push --quiet origin "$TARGET_BRANCH"
PUSHED=1
restore_stash_if_any
step_done
fi
# ── Done ───────────────────────────────────────────────────────────
rm -rf "$APP_DIR/build"
TOTAL=$((SECONDS - SHIP_START))
echo
hr
if [ "$DRY_RUN" = "1" ]; then
printf " ${BOLD}${GREEN}✓ Dry run complete${RESET} ${DIM}· would have shipped %s (%s) · %s${RESET}\n" \
"$NEW_VERSION" "$NEW_BUILD" "$(fmt_duration $TOTAL)"
printf '%s\n' " ${DIM}No merge, upload, or push happened. Working tree restored.${RESET}"
else
printf " ${BOLD}${GREEN}✓ Shipped %s (%s)${RESET} ${DIM}· total %s${RESET}\n" \
"$NEW_VERSION" "$NEW_BUILD" "$(fmt_duration $TOTAL)"
printf '%s\n' " ${DIM}TestFlight processing — appears in App Store Connect shortly.${RESET}"
fi
hr
echo
# Audible notification — handy when the archive runs unattended.
if [ "$DRY_RUN" = "0" ]; then
command -v say >/dev/null && say "TestFlight upload complete" &
fi