Skip to content
2 changes: 1 addition & 1 deletion docs/IOS-TESTFLIGHT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The ship script keeps its own rate gate and the same-HEAD skip. Never set `IOS_

## Versions And Notes

The marketing version train is `1.0.N` (`marketingVersionDefault` in `scripts/ios-fleet/apps.json`); the older `0.1.N` builds stay in TestFlight and `1.0.1` is above every one of them. The build number is the UTC `YYYYMMDDHHMM` stamp. Every upload publishes What to Test from the commit subjects since the last ship, with agent names removed.
Marketing and build both come from the repo's commit count: `1.0.<commit_count>` and `<commit_count>`, so the pair always describes one commit (the older `0.1.N` builds stay in TestFlight and `1.0.1` is above every one of them). UTC `YYYYMMDDHHMM` stamps are retired. Every upload publishes What to Test from the commit subjects since the last ship, with agent names removed.

## When A Build Is Missing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 234;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = CC8UTF7ATG;
ENABLE_NS_ASSERTIONS = NO;
Expand All @@ -650,7 +650,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
MARKETING_VERSION = 1.0.234;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -716,7 +716,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 234;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = CC8UTF7ATG;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand All @@ -738,7 +738,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
MARKETING_VERSION = 1.0.234;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
Expand Down
8 changes: 6 additions & 2 deletions ios/CodeCapsCompanion/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ settings:
SWIFT_VERSION: "5.0"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
MACOSX_DEPLOYMENT_TARGET: "14.0"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
# RECORD OF WHAT SHIPPED, not the source of truth for what ships next:
# the ship script passes both values on the xcodebuild command line, and
# reads this one as the build-number FLOOR, so it must stay below the
# commit count of any commit that can ship.
MARKETING_VERSION: "1.0.234"
CURRENT_PROJECT_VERSION: "234"
DEVELOPMENT_TEAM: CC8UTF7ATG
CODE_SIGN_STYLE: Automatic
ENABLE_USER_SCRIPT_SANDBOXING: YES
Expand Down
124 changes: 81 additions & 43 deletions scripts/ios-fleet/ship-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
# CURRENT_PROJECT_VERSION = <commit_count>
# App Store Connect renders "<marketing> (<build>)", so a ship shows
# "1.0.148 (148)". Unified across macOS and iOS; timestamps permanently retired.
# One exception: a re-ship of a commit count that App Store Connect already
# holds raises BOTH numbers to <floor + 1>, so the pair stays consistent but
# the build number no longer names that commit. See the guard below.
#
# ---------------------------------------------------------------------------
# WHAT IS HOLDING BACK AUTOMATIC SHIPPING: THE MIN-INTERVAL RATE LIMIT
Expand Down Expand Up @@ -362,6 +365,9 @@ link_private_key() {
# MARKETING_VERSION (CFBundleShortVersionString) is 1.0.<commit_count>.
# CURRENT_PROJECT_VERSION (CFBundleVersion) is the same integer commit count.
# ASC renders "<marketing> (<build>)", so a ship shows "1.0.259 (259)".
# One exception, the guard below: when App Store Connect already holds that
# commit count, both numbers move up to <floor + 1> together, so the pair
# still carries one number and that number no longer names a commit.
#
# The commit count replaces the UTC YYYYMMDDHHMM stamp for CFBundleVersion:
# - it names the EXACT commit a build came from, so a TestFlight or Sparkle
Expand All @@ -372,30 +378,24 @@ link_private_key() {
# - it is path-gated: neither macOS nor iOS bumps unless that platform.s
# native files changed (AGENT-SYNC.md, owner 2026-10-18)
#
# Sequence state: ${STATE_DIR}/build-seq-<app>.txt (atomic-mkdir-guarded).
# NOTE: flock(1) does not exist on macOS — under `set -e` a flock call would
# kill the subshell and yield an EMPTY sequence ("1.0."), so the mutex is an
# atomic mkdir instead (portable to Apple bash 3.2, per the header contract).
# The local build-seq counter is retired under the commit-count scheme, so it
# is no longer one of the floor's sources: local_seq() returns 0 and nothing
# reads or advances the counter file. The floor is
# max(App Store Connect, project.pbxproj)
# and the number to ship is the commit count, raised past that floor by the
# guard below. App Store Connect is the only record that shipping advances.
#
# That local file is a CACHE, not the source of truth. It is one unbacked file
# on one machine: if it is lost, reset, or a ship runs from a second machine,
# a bare "+1" reuses a build number and App Store Connect rejects the upload as
# a duplicate. So the next sequence is
# max(local cache, App Store Connect, project.pbxproj) + 1
# which is monotonic against every record we have. Taking the max can skip a
# number (harmless — numbers are free); reusing one is fatal.
# The counter file and next_build_seq() are left in place for now, so a
# vendored script stays close to the fleet original; neither is on the path
# that decides a build number.
local_seq() {
local n
n=$(cat "${STATE_DIR}/build-seq-${APP_KEY}.txt" 2>/dev/null || echo 0)
[[ "$n" =~ ^[0-9]+$ ]] || n=0
printf '%s' "$n"
}

# Legacy UTC minute stamp. RETIRED as the primary scheme 2026-10-18: CFBundleVersion
# is now the repo commit count, which is monotonic by construction and names the
# exact commit a build came from. Kept only as a fallback for a non-git checkout.
utc_build_stamp() {
date -u +%Y%m%d%H%M
# The build-seq counter is retired under the commit-count scheme: the
# ship script never advances it, so whatever is left in the file is a
# leftover from the retired timestamp scheme. Reading it would inflate
# the floor above the commit count, and the strictly-increasing guard
# would then ship a number that names no commit. The counter feeds
# neither MARKETING nor BUILD_NUM any more, so it contributes nothing.
printf '%s' "0"
}

# Highest N in MARKETING_VERSION = <prefix>.N across the project's build
Expand Down Expand Up @@ -489,18 +489,19 @@ resolve_seq_floor() {
# Treat "ASC unverified" as fatal on its own.
if [[ "$ALLOW_UNVERIFIED_SEQ" -eq 0 ]]; then
die "cannot determine the next build number.
App Store Connect could not be reached AND there is no local sequence
(${STATE_DIR}/build-seq-${APP_KEY}.txt) and no ${prefix}.N in project.pbxproj.
App Store Connect could not be reached. That alone is fatal: ASC is the
only record that is advanced by shipping, so without it a lost cache or
an unreachable API would silently reuse a build number.
Shipping now would very likely reuse a build number and be rejected as a duplicate.
Fix one of these, then re-run:
1) restore ASC access: check the ASC_* env or Infisical /asc (fleet-secrets) and that 'node' is on PATH, then
run: node ${FLEET_DIR}/asc-api.mjs latest-build-seq ${BUNDLE_ID} ${prefix}
2) or pass the number explicitly: --version ${prefix}.<N> (NOT --build <N>:
--version picks the marketing version and lets CFBundleVersion stay an
auto UTC timestamp, which is always higher than every build already
uploaded. A bare --build leaves MARKETING at ${DEFAULT_MARKETING}, which
re-enters an old train where Apple requires a strictly greater build than
everything in it -- rejected after the full archive+upload.)
2) or pass the number explicitly: --build <N>
--build is honoured exactly: CFBundleVersion becomes N, and MARKETING
moves with it to the default train's N, so the pair always describes
one number. To ship into a train other than the default, pass
--version <prefix>.N as well. N must be a plain integer above every
build already shipped in that train; UTC timestamps are retired.
3) or, only if you are certain this train is empty, re-run with --allow-unverified-seq"
fi
if [[ "$ALLOW_UNVERIFIED_SEQ" -eq 1 ]]; then
Expand Down Expand Up @@ -811,8 +812,10 @@ fi
# HEAD), EXPORT_ONLY, FORCE_SHIP, STATE_DIR. Nothing between here and the old
# call site set up anything it reads, so moving it earlier drops no step. What
# it now runs BEFORE is: PBXPROJ resolution, resolve_seq_floor (an App Store
# Connect round trip), next_build_seq (the consuming step), the project-version
# agreement check, --sync-project-version, load_secrets, and the output dirs.
# Connect round trip), the project-version agreement check,
# --sync-project-version, load_secrets, and the output dirs. The counter
# step is gone from that list: the local build-seq counter is retired, so no
# number is consumed from a cache any more.
# The dirty-worktree check stays ahead of the gate so a dirty tree still fails
# loudly rather than being masked by a quiet rate-limit skip.
#
Expand Down Expand Up @@ -861,12 +864,23 @@ if [[ -n "$UPLOAD_ONLY_IPA" ]]; then
BUILD_NUM="(from IPA)"
log "version: taken from the existing IPA (--upload-only); sequence neither consulted nor consumed"
elif [[ -n "$FORCE_BUILD" || -n "$FORCE_VERSION" ]]; then
# Explicit operator override: honour exactly what was asked. A bare --version
# still gets an auto timestamp build number, which is always higher than
# anything already uploaded — that is the whole point of the scheme, so an
# operator picking the marketing version does not have to reason about it.
# Explicit operator override: honour exactly what was asked. A bare --version
# still gets an auto build number from the repo commit count, so an operator
# picking the marketing version does not have to reason about it. The commit
# count is usually higher than anything already uploaded, but not always: the
# floor below raises the build number when it is not.
MARKETING="${FORCE_VERSION:-$DEFAULT_MARKETING}"
BUILD_NUM="${FORCE_BUILD:-$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null || date -u +%Y%m%d%H%M)}"
if [[ -n "$FORCE_BUILD" ]]; then
# An explicit --build is honoured exactly.
BUILD_NUM="$FORCE_BUILD"
else
# UTC timestamps are retired (owner 2026-10-18): CFBundleVersion is the
# repo commit count, so a checkout with no readable git history cannot be
# numbered at all. Fail loudly rather than mint a 12-digit stamp that no
# commit describes.
BUILD_NUM="$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null || true)"
[[ "$BUILD_NUM" =~ ^[0-9]+$ ]] || die "could not read the commit count from ${REPO_ROOT}; CFBundleVersion is the commit count and UTC timestamps are retired"
fi
# An explicit --build is honoured exactly. The auto-fill still needs the
# floor: --version against an already-shipped HEAD would otherwise ship a
# build at or below what App Store Connect already holds for that train.
Expand All @@ -876,14 +890,27 @@ elif [[ -n "$FORCE_BUILD" || -n "$FORCE_VERSION" ]]; then
# wrong here whenever --version named another one: reading 1.0's floor for a
# 2.0 ship is what produced stale builds like "2.0 (build 202510190002)".
_override_prefix="$(version_prefix "$MARKETING")"
_of_floor="$(resolve_seq_floor "$_override_prefix" "$PBXPROJ" "$PLATFORM" 2>/dev/null || echo 0)"
[[ "$_of_floor" =~ ^[0-9]+$ ]] || _of_floor=0
# Propagate the floor failure. Coercing it to 0 would let an
# unreachable App Store Connect pass as "floor 0", so the build number
# would stay at the commit count and could repeat a pair ASC already
# holds. The auto path below already refuses to ship in that case.
_of_floor="$(resolve_seq_floor "$_override_prefix" "$PBXPROJ" "$PLATFORM")" \
|| die "override path could not resolve the build floor; refusing to ship"
[[ "$_of_floor" =~ ^[0-9]+$ ]] || die "override floor is not a number: ${_of_floor}"
if [[ "$_of_floor" -ge "$BUILD_NUM" ]]; then
BUILD_NUM="$((_of_floor + 1))"
log "build: override would repeat an already-shipped build; using ${BUILD_NUM} instead"
fi
fi
log "version: operator override (--build/--version); sequence not consulted"
# The shipped pair must describe one number. Unless the operator pinned
# a marketing version, MARKETING follows the build, so the ship is
# 1.0.N (N) rather than a version and a build that disagree. An explicit
# --version names a train and is honoured exactly; the floor above still
# keeps the build unique inside that train.
if [[ -z "$FORCE_VERSION" ]]; then
MARKETING="${MARKETING_PREFIX}.${BUILD_NUM}"
fi
log "version: operator override (--build/--version); the local build-seq counter is not consulted"
else
# resolve_seq_floor runs in a subshell, so its die() cannot exit this script
# on its own — check explicitly rather than leaning on set -e.
Expand All @@ -893,15 +920,26 @@ else
# commit-count scheme it no longer feeds MARKETING or BUILD_NUM, so writing
# to it would only drift the floor on every later run and eventually push a
# fresh commit's build number higher than the commit count it names.
COMMIT_COUNT="$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null || echo "$((SEQ_FLOOR + 1))")"
# The build number names a commit, so it has to be the commit count. The
# old fallback invented SEQ_FLOOR + 1 here, which decoupled the shipped
# number from the commit it claims to be. The guard below is the one
# deliberate exception: when the floor is at or above the commit count,
# shipping the commit count would repeat a (version, build) pair App Store
# Connect already holds, so the pair is bumped together and describes one
# number that is not a commit count. That is the safe direction to be
# wrong in. A floor above the commit count also happens on a shallow
# checkout, where `git rev-list --count HEAD` understates the count; the
# committed record is what keeps the build number unique there.
COMMIT_COUNT="$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null || true)"
[[ "$COMMIT_COUNT" =~ ^[0-9]+$ ]] || die "could not read the commit count from ${REPO_ROOT}; the build number is the commit count and UTC timestamps are retired"
MARKETING="${MARKETING_PREFIX}.${COMMIT_COUNT}"
BUILD_NUM="${COMMIT_COUNT}"
Comment thread
jaywedgeworth22 marked this conversation as resolved.
# Strictly-increasing guard for a re-ship of the same commit: ASC rejects a
# repeated (version, build) pair, so never ship at or below the known floor.
#
# When the guard fires, MARKETING moves with BUILD_NUM. The invariant is
# that the two always describe the same commit count, so leaving MARKETING
# at the raw count would record a version whose build does not match it.
# that the two always carry the same number, so leaving MARKETING at the
# raw count would record a version whose build does not match it.
if [[ "$SEQ_FLOOR" -ge "$BUILD_NUM" ]]; then
BUILD_NUM="$((SEQ_FLOOR + 1))"
MARKETING="${MARKETING_PREFIX}.${BUILD_NUM}"
Expand Down
Loading