diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index c983b6eb..a7d3945f 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 278b7735..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 = 1; + 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 = 0.1.0; + 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 = 1; + 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 = 0.1.0; + 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 819c4a5e..245e09c2 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: "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 diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 75a6bf40..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,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-.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 = .N across the project's build @@ -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}. (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 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 @@ -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. # @@ -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. @@ -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. @@ -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}" # 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}"