From aa8049f3c8ad99aea8420ea1fd030dc4c0aa6259 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:19:33 -0500 Subject: [PATCH 1/6] build: align the version record with the 1.0. scheme MARKETING_VERSION and CURRENT_PROJECT_VERSION now describe the same commit count: MARKETING is 1.0.N and the build is N, where N is the commit count of the commit that carries the change. The retired UTC YYYYMMDDHHMM build stamp is gone from project.yml, and the committed Xcode project carries the same pair so it cannot re-introduce the old value when xcodegen regenerates it. --- .../CodeCapsCompanion.xcodeproj/project.pbxproj | 8 ++++---- ios/CodeCapsCompanion/project.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj index 278b7735..22353563 100644 --- a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj +++ b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj @@ -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 = 236; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = CC8UTF7ATG; ENABLE_NS_ASSERTIONS = NO; @@ -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.236; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -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 = 236; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = CC8UTF7ATG; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -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.236; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/ios/CodeCapsCompanion/project.yml b/ios/CodeCapsCompanion/project.yml index 69f02a1d..a75c7efe 100644 --- a/ios/CodeCapsCompanion/project.yml +++ b/ios/CodeCapsCompanion/project.yml @@ -15,8 +15,8 @@ settings: SWIFT_VERSION: "5.0" IPHONEOS_DEPLOYMENT_TARGET: "17.0" MACOSX_DEPLOYMENT_TARGET: "14.0" - MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.0.236" + CURRENT_PROJECT_VERSION: "236" DEVELOPMENT_TEAM: CC8UTF7ATG CODE_SIGN_STYLE: Automatic ENABLE_USER_SCRIPT_SANDBOXING: YES From 5d003e9271225bd32d6bdbc9c8012fa70e78132c Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:27:57 -0500 Subject: [PATCH 2/6] build: align the version record and the override path with 1.0. The committed version record carried the retired UTC YYYYMMDDHHMM build stamp and a MARKETING_VERSION that did not match its own build number. Under the unified scheme the two always describe one number: MARKETING is 1.0.N and the build is N, where N is the commit count of the commit that carries the change. The committed Xcode project carries the same pair, so a regeneration cannot re-introduce the old value. The ship script had two defects in the operator-override path. The floor failure was swallowed, so an unreachable App Store Connect read as "floor 0" and the build number could repeat a pair ASC already holds; the auto path already refuses to ship in that case, and the override path now does the same. And MARKETING did not follow BUILD_NUM, so a ship could record a version and a build that disagree. Unless the operator pins a marketing version, MARKETING is now derived from the build. An explicit --version names a train and is honoured exactly; the floor still keeps the build unique inside it. An explicit --build is still honoured exactly. The auto path no longer advances the local build-seq counter: under the commit-count scheme it feeds neither field, so writing to it only drifts the floor on every later run. --- .../project.pbxproj | 8 +++---- ios/CodeCapsCompanion/project.yml | 4 ++-- scripts/ios-fleet/ship-testflight.sh | 23 +++++++++++++++---- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj index 22353563..24691928 100644 --- a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj +++ b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj @@ -634,7 +634,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 237; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = CC8UTF7ATG; ENABLE_NS_ASSERTIONS = NO; @@ -650,7 +650,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.236; + MARKETING_VERSION = 1.0.237; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -716,7 +716,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 237; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = CC8UTF7ATG; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -738,7 +738,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.236; + MARKETING_VERSION = 1.0.237; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/ios/CodeCapsCompanion/project.yml b/ios/CodeCapsCompanion/project.yml index a75c7efe..ad2529c0 100644 --- a/ios/CodeCapsCompanion/project.yml +++ b/ios/CodeCapsCompanion/project.yml @@ -15,8 +15,8 @@ settings: SWIFT_VERSION: "5.0" IPHONEOS_DEPLOYMENT_TARGET: "17.0" MACOSX_DEPLOYMENT_TARGET: "14.0" - MARKETING_VERSION: "1.0.236" - CURRENT_PROJECT_VERSION: "236" + MARKETING_VERSION: "1.0.237" + CURRENT_PROJECT_VERSION: "237" DEVELOPMENT_TEAM: CC8UTF7ATG CODE_SIGN_STYLE: Automatic ENABLE_USER_SCRIPT_SANDBOXING: YES diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 75a6bf40..7c95f5ed 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -861,9 +861,9 @@ 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 + # Explicit operator override: honour exactly what was asked. A bare --version + # still gets an auto build number from the repo commit count, which is 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. 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)}" @@ -876,13 +876,26 @@ 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 + # 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); sequence not consulted" else # resolve_seq_floor runs in a subshell, so its die() cannot exit this script From deb58294908a1b3af13668b1629cbae6fcbe5ec8 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:10:57 -0500 Subject: [PATCH 3/6] build: put CodeCaps on the commit-count scheme and retire the UTC stamp MARKETING_VERSION is 1.0. and CURRENT_PROJECT_VERSION is that same integer, so the pair App Store Connect renders always names one commit. Three defects are fixed in this change: - The committed version record was set to the branch-tip commit count, which after a squash merge names a count that will never exist on main. The record is a build-number floor input, so that made it the dominant floor and permanently shipped build numbers above the commit count they were supposed to name. The record now names 234, one below main's count: a real commit, below every shipping commit, safe under any merge strategy. - The ship script still fell back to a UTC YYYYMMDDHHMM stamp when the commit count could not be read. The retired scheme must not be one error away, so a checkout with no readable git history now fails loudly instead of minting a number that describes no commit. - The local build-seq counter is no longer advanced. Under the commit-count scheme it feeds neither MARKETING nor BUILD_NUM, and writing floor + 1 to it would record a number that never shipped. Docs that still described the retired stamp are corrected in the same change, so a later reader cannot reintroduce it. Fixes AFC-356 --- docs/IOS-TESTFLIGHT.md | 2 +- .../project.pbxproj | 8 ++--- ios/CodeCapsCompanion/project.yml | 8 +++-- scripts/ios-fleet/ship-testflight.sh | 36 +++++++++++-------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index 6f411c6a..02455f20 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -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.` and ``, 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 diff --git a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj index 24691928..33e70904 100644 --- a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj +++ b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj @@ -634,7 +634,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 237; + CURRENT_PROJECT_VERSION = 234; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = CC8UTF7ATG; ENABLE_NS_ASSERTIONS = NO; @@ -650,7 +650,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.237; + MARKETING_VERSION = 1.0.234; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -716,7 +716,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 237; + CURRENT_PROJECT_VERSION = 234; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = CC8UTF7ATG; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -738,7 +738,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.237; + MARKETING_VERSION = 1.0.234; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/ios/CodeCapsCompanion/project.yml b/ios/CodeCapsCompanion/project.yml index ad2529c0..f7139f74 100644 --- a/ios/CodeCapsCompanion/project.yml +++ b/ios/CodeCapsCompanion/project.yml @@ -15,8 +15,12 @@ settings: SWIFT_VERSION: "5.0" IPHONEOS_DEPLOYMENT_TARGET: "17.0" MACOSX_DEPLOYMENT_TARGET: "14.0" - MARKETING_VERSION: "1.0.237" - CURRENT_PROJECT_VERSION: "237" + # 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 diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 7c95f5ed..f426bb7f 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -391,13 +391,6 @@ local_seq() { 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 -} - # Highest N in MARKETING_VERSION = .N across the project's build # configurations. 0 when the file is unreadable or holds no on-train value. project_marketing_seq() { @@ -495,12 +488,11 @@ resolve_seq_floor() { 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}. (NOT --build : - --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 + --build is honoured exactly: CFBundleVersion becomes N and MARKETING + moves with it to ${prefix}.N, so the pair always describes one + number. N must be a plain integer above every build already shipped + in this 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 @@ -866,7 +858,17 @@ elif [[ -n "$FORCE_BUILD" || -n "$FORCE_VERSION" ]]; then # 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. 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)" + [[ "$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. @@ -906,7 +908,11 @@ 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. + COMMIT_COUNT="$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null)" + [[ "$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}" # Strictly-increasing guard for a re-ship of the same commit: ASC rejects a From ae2a8b67f1d361e139bb26a5dbaa5de565fecfb9 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:53:58 -0500 Subject: [PATCH 4/6] build: make the ship script fail loudly and stop reading the retired counter Four problems are fixed here. A git failure while reading the commit count exited under set -e before the diagnostic could run, so the run died with no explanation at all. The retired build-seq counter was still being read as a floor input, and a leftover from the old timestamp scheme in that file could push the build number above the commit count it names. The die text blamed "there is no local sequence" for a cause that no longer exists, and the --build guidance described the wrong train. The guard comment also did not say that the guard is the one deliberate exception to the commit-count rule. The counter is never written any more, so it is retired rather than merely un-advanced: local_seq() now returns 0, and the committed version record is the floor input that remains. An explicit --build is still honoured exactly, and it moves MARKETING to the default train's N; --version .N targets a train other than the default. Fixes AFC-356 --- scripts/ios-fleet/ship-testflight.sh | 38 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index f426bb7f..e33576ea 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -385,10 +385,13 @@ link_private_key() { # which is monotonic against every record we have. Taking the max can skip a # number (harmless — numbers are free); reusing one is fatal. 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" + # 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 = .N across the project's build @@ -482,17 +485,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: --build - --build is honoured exactly: CFBundleVersion becomes N and MARKETING - moves with it to ${prefix}.N, so the pair always describes one - number. N must be a plain integer above every build already shipped - in this train; UTC timestamps are retired. + --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 .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 @@ -866,7 +871,7 @@ elif [[ -n "$FORCE_BUILD" || -n "$FORCE_VERSION" ]]; then # 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)" + 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 @@ -910,8 +915,15 @@ else # fresh commit's build number higher than the commit count it names. # 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. - COMMIT_COUNT="$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null)" + # 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}" From e7cc7945ced6db314bf373879b20699612886bd9 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:27:14 -0500 Subject: [PATCH 5/6] fix(ios-fleet): say what the override branch does not consult The override log line read "sequence not consulted". That is wrong for a --version-only override: resolve_seq_floor runs whenever --build is absent, so the App Store Connect floor IS consulted there. What is genuinely not consulted is the retired local build-seq counter. The line now names the local counter, so an operator reading the log is not misled about whether the floor was checked. --- scripts/ios-fleet/ship-testflight.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index e33576ea..1b206862 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -903,7 +903,7 @@ elif [[ -n "$FORCE_BUILD" || -n "$FORCE_VERSION" ]]; then if [[ -z "$FORCE_VERSION" ]]; then MARKETING="${MARKETING_PREFIX}.${BUILD_NUM}" fi - log "version: operator override (--build/--version); sequence not consulted" + 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. From de4a5d2c71efd5d7ff75c8147fa9c26c2ed89747 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:52:51 -0500 Subject: [PATCH 6/6] fix(ios-fleet): correct what the version comments claim The vendored ship script's comments still described a scheme the code no longer implements. This is the same documentation fix that landed in the FleetLink copy, applied here so the two do not drift apart. Four claims were wrong: - The header said the version is always 1.0. with no exception, and the MARKETING_VERSION block repeated that. When App Store Connect already holds that commit count, the guard moves BOTH numbers up to together, so the pair stays consistent but the build number no longer names that commit. The header now states that one exception. - The sequence block described the next sequence as max(local cache, App Store Connect, project.pbxproj) + 1. The local build-seq counter is retired under the commit-count scheme: local_seq() returns 0 and nothing reads or advances the 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. The counter file and next_build_seq() stay in place so a vendored script stays close to the fleet original, but neither is on the path that decides a build number. - The ship-gate list still named next_build_seq as "the consuming step". No number is consumed from a cache any more, so it is off that list. - The override branch claimed the commit count "is higher than anything already uploaded". It is usually higher, but not always; the floor raises the build number when it is not. The guard comment also said the two numbers "always describe the same commit count" while the comment directly above it said they carry one number; it now says the latter. Every changed line is a comment. No behaviour changes. --- scripts/ios-fleet/ship-testflight.sh | 43 ++++++++++++++++------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 1b206862..53a0ada8 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -24,6 +24,9 @@ # CURRENT_PROJECT_VERSION = # App Store Connect renders " ()", 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 , 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 @@ -362,6 +365,9 @@ link_private_key() { # MARKETING_VERSION (CFBundleShortVersionString) is 1.0.. # CURRENT_PROJECT_VERSION (CFBundleVersion) is the same integer commit count. # ASC renders " ()", 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 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 @@ -372,18 +378,16 @@ 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-.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() { # 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 @@ -808,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. # @@ -859,9 +865,10 @@ if [[ -n "$UPLOAD_ONLY_IPA" ]]; then 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 build number from the repo commit count, which is 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. + # 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}" if [[ -n "$FORCE_BUILD" ]]; then # An explicit --build is honoured exactly. @@ -931,8 +938,8 @@ else # 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}"