From 4c641870fe30853d2b1531d12cca78ec2723d107 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" Date: Mon, 26 Oct 2026 18:12:16 -0500 Subject: [PATCH 1/6] ios-fleet: fixed marketing 0.1.0 with a dotted build (0.1.0 (0.1.0), then 0.1.1) --- scripts/ios-fleet/apps.json | 1 + scripts/ios-fleet/asc-api.mjs | 117 +++++++++++++++++++++++++++ scripts/ios-fleet/ship-testflight.sh | 63 ++++++++++++++- 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/scripts/ios-fleet/apps.json b/scripts/ios-fleet/apps.json index 05b4d946..0a017add 100644 --- a/scripts/ios-fleet/apps.json +++ b/scripts/ios-fleet/apps.json @@ -2,6 +2,7 @@ "teamId": "CC8UTF7ATG", "apps": { "codecaps": { + "fixedMarketingVersion": "0.1.0", "displayName": "CodeCaps Companion", "bundleId": "com.simplewithus.codecaps.ios", "appleId": 6814623846, diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 9e89bdcc..f6384c63 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -2441,6 +2441,123 @@ async function main() { return; } + // Highest App Store version that has been (or is being) released, i.e. whose + // train is closed to a lower or equal version. Prints the version string, + // or nothing when the app has none. READ ONLY. + if (method === "released-store-version") { + const bundleId = path; + const platform = body || "IOS"; + if (!bundleId) { + console.error("Usage: node asc-api.mjs released-store-version [platform=IOS]"); + process.exit(2); + } + const appId = await appIdForBundle(bundleId); + if (!appId) { + console.error(`released-store-version: app not found for bundle ${bundleId}`); + process.exit(2); + } + const storeData = []; + let storeUrl = `/v1/apps/${appId}/appStoreVersions?filter[platform]=${encodeURIComponent(platform)}&limit=200`; + for (let page = 0; page < 10 && storeUrl; page += 1) { + const storeRes = await ascGet(storeUrl); + if (!storeRes.ok) { + console.error(`released-store-version: appStoreVersions query failed (HTTP ${storeRes.status})`); + failExit(storeRes.status); + } + storeData.push(...(storeRes.parsed.data || [])); + storeUrl = storeRes.parsed.links?.next || ""; + } + if (storeUrl) { + console.error("released-store-version: more than 10 pages of App Store versions; refusing to guess"); + process.exit(2); + } + const RELEASED = new Set([ + "READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", + "REMOVED_FROM_SALE", "PENDING_DEVELOPER_RELEASE", "PENDING_APPLE_RELEASE", + "PROCESSING_FOR_DISTRIBUTION", "PROCESSING_FOR_APP_STORE" + ]); + const parse = (v) => String(v).split(".").map((n) => parseInt(n, 10) || 0); + const cmp = (a, b) => { + const x = parse(a), y = parse(b); + for (let i = 0; i < Math.max(x.length, y.length); i += 1) { + const d = (x[i] || 0) - (y[i] || 0); + if (d) return d; + } + return 0; + }; + let best = ""; + for (const v of storeData) { + const a = v?.attributes || {}; + if (!a.versionString) continue; + if (!RELEASED.has(a.appVersionState) && !RELEASED.has(a.appStoreState)) continue; + if (!best || cmp(a.versionString, best) > 0) best = a.versionString; + } + console.error(`released-store-version: ${storeData.length} App Store version(s) inspected on ${platform}; highest released = ${best || "none"}`); + console.log(best); + process.exit(0); + } + + // Next dotted build for a fixed marketing version M.m.p: the train's builds + // must be M.m., and the next one is M.m.; an empty train starts + // at M.m.p, so the first build reads like its version (1.1.2 (1.1.2)). A + // train that holds any build of another shape stops the run with the reason, + // because the ordering between shapes (239 is 239.0.0, above 1.1.2) is not + // something to guess. READ ONLY. + // + // Two ships at once would read the same train and pick the same n. The CI + // ship is serialised (ios-ship.yml concurrency group, cancel-in-progress + // false), and a duplicate CFBundleVersion is refused by App Store Connect at + // upload, so the loser fails loudly and nothing is promoted. Do not run a + // local ship while the workflow is running. + if (method === "next-dotted-build") { + const bundleId = path; + const version = body; + const platform = arg4 || "IOS"; + const vm = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || ""); + if (!bundleId || !vm) { + console.error("Usage: node asc-api.mjs next-dotted-build [platform=IOS]"); + process.exit(2); + } + const appId = await appIdForBundle(bundleId); + if (!appId) { + console.error(`next-dotted-build: app not found for bundle ${bundleId}`); + process.exit(2); + } + const prefix = `${vm[1]}.${vm[2]}.`; + let max = -1; + let seen = 0; + let nextUrl = + `/v1/builds?filter[app]=${appId}` + + `&filter[preReleaseVersion.version]=${encodeURIComponent(version)}` + + `&filter[preReleaseVersion.platform]=${encodeURIComponent(platform)}&limit=200`; + for (let page = 0; page < 10 && nextUrl; page++) { + const res = await ascGet(nextUrl); + if (!res.ok) { + console.error(`next-dotted-build: train builds query failed (HTTP ${res.status})`); + failExit(res.status); + } + for (const b of res.parsed.data || []) { + const bv = b?.attributes?.version; + const bm = typeof bv === "string" && bv.startsWith(prefix) ? /^\d+$/.exec(bv.slice(prefix.length)) : null; + if (!bm) { + console.error(`next-dotted-build: train ${version} holds build ${JSON.stringify(bv ?? null)}, which is not ${prefix}; not guessing how it orders against a dotted build`); + process.exit(3); + } + seen += 1; + max = Math.max(max, parseInt(bm[0], 10)); + } + nextUrl = res.parsed.links?.next || ""; + } + if (nextUrl) { + console.error("next-dotted-build: more than 10 pages of builds in one train; refusing to guess"); + process.exit(2); + } + const next = max < 0 ? parseInt(vm[3], 10) : max + 1; + console.error(`next-dotted-build: ${seen} build(s) in train ${version}; next is ${prefix}${next}`); + console.log(`${prefix}${next}`); + process.exit(0); + } + // The build number the next ship has to exceed: every build already in the // target train, plus the app's plain-integer builds below 1000000000 (every // build on macOS, where build numbers grow across all trains). diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index ae08ce90..8a2ca2c5 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -672,6 +672,30 @@ project_marketing_seq() { # Prints the integer on stdout. Returns 1 (and prints nothing) when ASC # cannot be consulted -- the caller must distinguish "no patch yet" (0) from # "unknown" (failure). +asc_next_dotted_build() { + local version="$1" plat="${2:-}" out rc + command -v "$ASC_NODE" >/dev/null 2>&1 || return 1 + load_secrets >/dev/null || return 1 + set +e + out=$(asc_client next-dotted-build "$BUNDLE_ID" "$version" "$plat") + rc=$? + set -e + [[ $rc -eq 0 && "$out" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + printf '%s' "$out" +} + +asc_released_store_version() { + local plat="${1:-}" out rc + command -v "$ASC_NODE" >/dev/null 2>&1 || return 1 + load_secrets >/dev/null || return 1 + set +e + out=$(asc_client released-store-version "$BUNDLE_ID" "$plat" 2>/dev/null) + rc=$? + set -e + [[ $rc -eq 0 ]] || return 1 + printf '%s' "$out" +} + asc_latest_marketing_patch() { local prefix="$1" plat="${2:-}" out rc if ! command -v "$ASC_NODE" >/dev/null 2>&1; then @@ -995,6 +1019,12 @@ PROJECT_REL="$(json_get "$APP_KEY" projectRel)" PROJECT_REL_ALT="$(json_get "$APP_KEY" projectRelAlt)" XCODEGEN_DIR="$(json_get "$APP_KEY" xcodegenDir)" DEFAULT_MARKETING="$(json_get "$APP_KEY" marketingVersionDefault)" +# Optional fixed-version policy (owner, 2026-10-26): the marketing version does +# not count up per TestFlight ship; the build is a dotted M.m. (see +# asc-api.mjs next-dotted-build) that goes up inside the fixed train. apps.json +# key: fixedMarketingVersion ("1.1.2"). Absent key keeps the next-patch +# behaviour. Not used on macOS (builds must grow across all trains). +FIXED_MARKETING="$(json_get "$APP_KEY" fixedMarketingVersion)" if [[ -z "$PLATFORM" ]]; then if [[ "$APP_KEY" == *-mac || "$SCHEME" == *[Mm]ac* ]]; then @@ -1092,6 +1122,7 @@ PLAN_FLOOR="n/a" PLAN_TRAIN_FLOOR="n/a" PLAN_APP_FLOOR="n/a" PLAN_COMMIT_COUNT="n/a" +FIXED_MODE=0 PLAN_READY=0 if [[ -n "$UPLOAD_ONLY_IPA" ]]; then @@ -1120,6 +1151,24 @@ else # ---- the marketing version --------------------------------------------- # --version is an exact override. Otherwise the version is the next patch # on the default train, and P comes from App Store Connect. + FIXED_MODE=0 + if [[ -z "$FORCE_VERSION" && -n "$FIXED_MARKETING" ]]; then + FORCE_VERSION="$FIXED_MARKETING" + FIXED_MODE=1 + log "version: fixed marketing version ${FIXED_MARKETING} from apps.json; it does not count up per ship" + # A released App Store version closes its train: a fixed version at or + # below one is refused, never silently bumped. + _rel="$(asc_released_store_version "$ASC_PLATFORM" 2>/dev/null)" \ + || die "cannot read the released App Store version for ${BUNDLE_ID}, so fixedMarketingVersion ${FIXED_MARKETING} cannot be checked against a closed train. Nothing was shipped or bumped." + if [[ -n "$_rel" ]]; then + if ! python3 -c 'import sys +a=[int(x) for x in sys.argv[1].split(".")]; b=[int(x) for x in sys.argv[2].split(".")] +n=max(len(a),len(b)); a+= [0]*(n-len(a)); b+=[0]*(n-len(b)) +sys.exit(0 if a>b else 1)' "$FIXED_MARKETING" "$_rel"; then + die "fixedMarketingVersion ${FIXED_MARKETING} is at or below the released App Store version ${_rel}. Apple requires each new version to be greater than the last released one. Nothing was bumped. Pick a version above ${_rel} in apps.json." + fi + fi + fi if [[ -n "$FORCE_VERSION" ]]; then MARKETING="$FORCE_VERSION" # The patch the operator pinned, on its own train ("2.0.5" -> 5). @@ -1174,6 +1223,16 @@ else die "--build ${BUILD_NUM} equals the patch of ${MARKETING}, which would ship ${MARKETING} (${BUILD_NUM}): the 1.0.N (N) form the owner retired (AFC-517). Pass a different --build." fi log "version: operator override (--build/--version); the local build-seq counter is not consulted" + elif [[ "$FIXED_MODE" -eq 1 ]]; then + if [[ "$(printf '%s' "${ASC_PLATFORM:-}" | tr a-z A-Z)" == "MAC_OS" ]]; then + die "fixedMarketingVersion is not supported on macOS: Mac builds must grow across every train, so a dotted build under a fixed version cannot be ordered safely. Nothing was shipped." + fi + # The build reads like the version it ships under (1.1.2 (1.1.2)) and goes + # up inside the fixed train (1.1.3, 1.1.4, ...). No floor, seed, count or + # patch bump applies: the train itself is the only record. + BUILD_NUM="$(asc_next_dotted_build "$MARKETING" "$ASC_PLATFORM")" \ + || die "cannot work out the next dotted build for ${MARKETING}: App Store Connect could not be read, or the train holds a build that is not ${MARKETING%.*}.. Nothing was shipped or guessed." + log "build: dotted build ${BUILD_NUM} for fixed version ${MARKETING}" else [[ "$PLAN_COMMIT_COUNT" =~ ^[0-9]+$ ]] || die "could not read the commit count from ${REPO_ROOT}; CFBundleVersion is the repo commit count and UTC timestamps are retired" @@ -1224,13 +1283,13 @@ else # count -- so the log line names the floor rather than asserting the # commit count was shipped. BUILD_NUM="$PLAN_COMMIT_COUNT" - if [[ "$BUILD_NUM" -le "$PLAN_FLOOR" ]]; then + if [[ "$FIXED_MODE" -ne 1 && "$BUILD_NUM" -le "$PLAN_FLOOR" ]]; then BUILD_NUM="$((PLAN_FLOOR + 1))" log "build: floor ${PLAN_FLOOR} is at or above the commit count ${PLAN_COMMIT_COUNT}; using ${BUILD_NUM} instead" fi # A ship must never look like "1.0.N (N)": if the build number happens # to be the patch number of the marketing version, move it one up. - if [[ -n "$PLAN_TARGET_PATCH" && "$BUILD_NUM" -eq "$PLAN_TARGET_PATCH" ]]; then + if [[ "$FIXED_MODE" -ne 1 && -n "$PLAN_TARGET_PATCH" && "$BUILD_NUM" -eq "$PLAN_TARGET_PATCH" ]]; then _bumped="$((BUILD_NUM + 1))" log "build: ${BUILD_NUM} equals the patch number; using ${_bumped} so the ship does not read ${MARKETING} (${BUILD_NUM})" BUILD_NUM="$_bumped" From 8cbadc85da9f5f2b3cd52524e7f5088da465e7e6 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" Date: Mon, 26 Oct 2026 18:27:17 -0500 Subject: [PATCH 2/6] ios-fleet: fixed-mode review fixes (app lookup per repo, platform case, --build/--version/macOS guards, floor at patch, unknown store states fail closed) --- scripts/ios-fleet/asc-api.mjs | 33 +++++++++++++++++++++++----- scripts/ios-fleet/ship-testflight.sh | 10 ++++++++- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index f6384c63..dcf77beb 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -2446,12 +2446,17 @@ async function main() { // or nothing when the app has none. READ ONLY. if (method === "released-store-version") { const bundleId = path; - const platform = body || "IOS"; + const platform = String(body || "IOS").toUpperCase(); if (!bundleId) { console.error("Usage: node asc-api.mjs released-store-version [platform=IOS]"); process.exit(2); } - const appId = await appIdForBundle(bundleId); + const app = await appIdForBundle(bundleId); + if (!app.ok) { + console.error(`released-store-version: app not found for bundle ${bundleId}: ${app.error}`); + process.exit(2); + } + const appId = app.appId; if (!appId) { console.error(`released-store-version: app not found for bundle ${bundleId}`); process.exit(2); @@ -2476,6 +2481,10 @@ async function main() { "REMOVED_FROM_SALE", "PENDING_DEVELOPER_RELEASE", "PENDING_APPLE_RELEASE", "PROCESSING_FOR_DISTRIBUTION", "PROCESSING_FOR_APP_STORE" ]); + const NOT_RELEASED = new Set([ + "PREPARE_FOR_SUBMISSION", "READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", + "UNRESOLVED_ISSUES", "REJECTED", "METADATA_REJECTED", "DEVELOPER_REJECTED", "INVALID_BINARY" + ]); const parse = (v) => String(v).split(".").map((n) => parseInt(n, 10) || 0); const cmp = (a, b) => { const x = parse(a), y = parse(b); @@ -2489,7 +2498,14 @@ async function main() { for (const v of storeData) { const a = v?.attributes || {}; if (!a.versionString) continue; - if (!RELEASED.has(a.appVersionState) && !RELEASED.has(a.appStoreState)) continue; + const st = [a.appVersionState, a.appStoreState].filter(Boolean); + if (!st.some((x) => RELEASED.has(x))) { + if (st.length === 0 || !st.every((x) => NOT_RELEASED.has(x))) { + console.error(`released-store-version: version ${a.versionString} has state ${JSON.stringify(st)}, which is not a known released or not-released state; not guessing`); + process.exit(3); + } + continue; + } if (!best || cmp(a.versionString, best) > 0) best = a.versionString; } console.error(`released-store-version: ${storeData.length} App Store version(s) inspected on ${platform}; highest released = ${best || "none"}`); @@ -2512,13 +2528,18 @@ async function main() { if (method === "next-dotted-build") { const bundleId = path; const version = body; - const platform = arg4 || "IOS"; + const platform = String(arg4 || "IOS").toUpperCase(); const vm = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || ""); if (!bundleId || !vm) { console.error("Usage: node asc-api.mjs next-dotted-build [platform=IOS]"); process.exit(2); } - const appId = await appIdForBundle(bundleId); + const app = await appIdForBundle(bundleId); + if (!app.ok) { + console.error(`next-dotted-build: app not found for bundle ${bundleId}: ${app.error}`); + process.exit(2); + } + const appId = app.appId; if (!appId) { console.error(`next-dotted-build: app not found for bundle ${bundleId}`); process.exit(2); @@ -2552,7 +2573,7 @@ async function main() { console.error("next-dotted-build: more than 10 pages of builds in one train; refusing to guess"); process.exit(2); } - const next = max < 0 ? parseInt(vm[3], 10) : max + 1; + const next = Math.max(max + 1, parseInt(vm[3], 10)); console.error(`next-dotted-build: ${seen} build(s) in train ${version}; next is ${prefix}${next}`); console.log(`${prefix}${next}`); process.exit(0); diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 8a2ca2c5..fc228eb4 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -1152,7 +1152,12 @@ else # --version is an exact override. Otherwise the version is the next patch # on the default train, and P comes from App Store Connect. FIXED_MODE=0 - if [[ -z "$FORCE_VERSION" && -n "$FIXED_MARKETING" ]]; then + if [[ -n "$FIXED_MARKETING" && ( -z "$FORCE_VERSION" || "$FORCE_VERSION" == "$FIXED_MARKETING" ) ]]; then + [[ "$FIXED_MARKETING" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || die "fixedMarketingVersion '${FIXED_MARKETING}' for ${APP_KEY} in apps.json is not M.m.p (three dot-separated integers). Nothing was shipped." + if [[ "$(printf '%s' "${ASC_PLATFORM:-}" | tr a-z A-Z)" == "MAC_OS" ]]; then + die "fixedMarketingVersion is not supported on macOS: Mac builds must grow across every train, so a dotted build under a fixed version cannot be ordered safely. Nothing was shipped." + fi FORCE_VERSION="$FIXED_MARKETING" FIXED_MODE=1 log "version: fixed marketing version ${FIXED_MARKETING} from apps.json; it does not count up per ship" @@ -1213,6 +1218,9 @@ sys.exit(0 if a>b else 1)' "$FIXED_MARKETING" "$_rel"; then # ---- the build number --------------------------------------------------- if [[ -n "$FORCE_BUILD" ]]; then + if [[ "$FIXED_MODE" -eq 1 && ! "$FORCE_BUILD" =~ ^${MARKETING%.*}\.[0-9]+$ ]]; then + die "--build ${FORCE_BUILD} is not ${MARKETING%.*}.: in fixed-version mode the train holds only dotted builds of that shape, and a different one would block every later ship. Pass --build ${MARKETING%.*}. or leave --build off. Nothing was shipped." + fi # An explicit --build is honoured exactly, and it does NOT move the # marketing version: the two numbers are independent. BUILD_NUM="$FORCE_BUILD" From abd81f21027120d036bcac5a19bdab964c60a9c6 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" Date: Mon, 26 Oct 2026 18:51:22 -0500 Subject: [PATCH 3/6] ios-fleet: complete AppStoreVersionState allowlists for the released-version check --- scripts/ios-fleet/asc-api.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index dcf77beb..e88229bc 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -2478,11 +2478,13 @@ async function main() { } const RELEASED = new Set([ "READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", - "REMOVED_FROM_SALE", "PENDING_DEVELOPER_RELEASE", "PENDING_APPLE_RELEASE", + "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "PREORDER_READY_FOR_SALE", + "PENDING_DEVELOPER_RELEASE", "PENDING_APPLE_RELEASE", "ACCEPTED", "PROCESSING_FOR_DISTRIBUTION", "PROCESSING_FOR_APP_STORE" ]); const NOT_RELEASED = new Set([ "PREPARE_FOR_SUBMISSION", "READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", + "WAITING_FOR_EXPORT_COMPLIANCE", "PENDING_CONTRACT", "UNRESOLVED_ISSUES", "REJECTED", "METADATA_REJECTED", "DEVELOPER_REJECTED", "INVALID_BINARY" ]); const parse = (v) => String(v).split(".").map((n) => parseInt(n, 10) || 0); From afaab117ef240b8f1e74504d1392bd8fb04093cb Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" Date: Mon, 26 Oct 2026 18:55:05 -0500 Subject: [PATCH 4/6] ios-fleet: treat post-review contract/export-compliance states as released (fail closed) --- scripts/ios-fleet/asc-api.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index e88229bc..254dd997 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -2480,11 +2480,13 @@ async function main() { "READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "PREORDER_READY_FOR_SALE", "PENDING_DEVELOPER_RELEASE", "PENDING_APPLE_RELEASE", "ACCEPTED", + // post-review, waiting on contract or export compliance: the version is approved + // and holds its train, so it counts as released (fail closed) + "PENDING_CONTRACT", "WAITING_FOR_EXPORT_COMPLIANCE", "PROCESSING_FOR_DISTRIBUTION", "PROCESSING_FOR_APP_STORE" ]); const NOT_RELEASED = new Set([ "PREPARE_FOR_SUBMISSION", "READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", - "WAITING_FOR_EXPORT_COMPLIANCE", "PENDING_CONTRACT", "UNRESOLVED_ISSUES", "REJECTED", "METADATA_REJECTED", "DEVELOPER_REJECTED", "INVALID_BINARY" ]); const parse = (v) => String(v).split(".").map((n) => parseInt(n, 10) || 0); From 94434b78d510ae0ca2a4a5a92d0a95dacd622af5 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" Date: Mon, 26 Oct 2026 21:46:01 -0500 Subject: [PATCH 5/6] ios-fleet: do not call load_secrets inside the version helpers' command substitutions Both helpers run in a subshell, where the secrets temp dir and its EXIT trap die and the key is left behind. They check asc_credential_available instead, and a test guards it. --- scripts/ios-fleet/ship-testflight.sh | 21 +++++++++++++++++++-- scripts/ios-fleet/test-asc-credentials.sh | 12 ++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index fc228eb4..fc07a96d 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -385,6 +385,17 @@ asc_client() { "$ASC_NODE" "${FLEET_DIR}/asc-api.mjs" "$@" } +# Is an ASC credential obtainable AT ALL? load_secrets() has not run yet when +# the version queries below run, so this cannot rely on the exported ASC_* +# names -- it checks each source asc-api.mjs knows about. +asc_credential_available() { + [[ -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER_ID:-}" && -n "${APPLE_API_KEY_P8_BASE64:-}" ]] && return 0 + [[ -n "${ASC_KEY_ID:-}" && -n "${ASC_ISSUER_ID:-}" && -n "${ASC_KEY_P8:-}" ]] && return 0 + [[ -n "${FLEET_SECRETS_BIN:-}" && -x "$FLEET_SECRETS_BIN" ]] && return 0 + command -v fleet-secrets >/dev/null 2>&1 && return 0 + return 1 +} + # The credential sources the client can name in a rejection, and the ONLY # sources this script reads out of a client's diagnostic. # @@ -675,7 +686,10 @@ project_marketing_seq() { asc_next_dotted_build() { local version="$1" plat="${2:-}" out rc command -v "$ASC_NODE" >/dev/null 2>&1 || return 1 - load_secrets >/dev/null || return 1 + # Not load_secrets: both helpers run inside command substitutions, where a + # secrets temp dir and its EXIT trap die with the subshell and leave the + # key behind. asc-api.mjs resolves its own credential. + asc_credential_available || return 1 set +e out=$(asc_client next-dotted-build "$BUNDLE_ID" "$version" "$plat") rc=$? @@ -687,7 +701,10 @@ asc_next_dotted_build() { asc_released_store_version() { local plat="${1:-}" out rc command -v "$ASC_NODE" >/dev/null 2>&1 || return 1 - load_secrets >/dev/null || return 1 + # Not load_secrets: both helpers run inside command substitutions, where a + # secrets temp dir and its EXIT trap die with the subshell and leave the + # key behind. asc-api.mjs resolves its own credential. + asc_credential_available || return 1 set +e out=$(asc_client released-store-version "$BUNDLE_ID" "$plat" 2>/dev/null) rc=$? diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index 25ede464..936cc941 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -1274,4 +1274,16 @@ bypass_out="$(REJECTED_KEY_FILE="${tmp}/sentinel-bypass" _rejected_key_path="${t [[ "$(cat "${tmp}/sentinel-bypass")" == "SENTINEL-BYPASS" ]] || fail "the hand-off path was taken from the environment" echo "ok: ASC credential resolution (environment first, then Infisical, a named path ignored, temp key removed)" +# The version helpers run inside command substitutions, where load_secrets +# would write a temp key whose cleanup trap dies with the subshell. They must +# check for a credential instead. +for fn in asc_next_dotted_build asc_released_store_version; do + body="$(awk -v f="$fn" '$0 ~ "^"f"\\(\\) \\{" {g=1} g {print} g && /^}/ {exit}' "$script")" + [[ -n "$body" ]] || fail "could not read ${fn}" + if grep -v '^[[:space:]]*#' <<<"$body" | grep -q 'load_secrets'; then + fail "${fn} calls load_secrets inside a command substitution (leaks the temp key dir)" + fi + grep -q 'asc_credential_available' <<<"$body" || fail "${fn} does not check for a credential" +done + echo "ok: the rejected-key label is parsed from the real client diagnostic, and the run-owned hand-off path is created at load" From 84005ff22c34424de99cba909ea9dd9c76d6fa6c Mon Sep 17 00:00:00 2001 From: Instinct Agent Date: Mon, 26 Oct 2026 22:01:34 -0500 Subject: [PATCH 6/6] ios-fleet: keep the released-version helper's reason and auth status (exit 4) instead of discarding stderr --- scripts/ios-fleet/ship-testflight.sh | 16 ++++++++++++++-- scripts/ios-fleet/test-asc-credentials.sh | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index fc07a96d..92269f33 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -705,10 +705,22 @@ asc_released_store_version() { # secrets temp dir and its EXIT trap die with the subshell and leave the # key behind. asc-api.mjs resolves its own credential. asc_credential_available || return 1 + # Keep the client's stderr: it carries the reason (HTTP status, unknown + # state). Only the labelled lines are logged, never key material, and an + # auth failure keeps its own status 4. + local errf + errf="$(mktemp "${TMPDIR:-/tmp}/asc-released-err.XXXXXX")" || return 1 set +e - out=$(asc_client released-store-version "$BUNDLE_ID" "$plat" 2>/dev/null) + out=$(asc_client released-store-version "$BUNDLE_ID" "$plat" 2>"$errf") rc=$? set -e + if [[ -s "$errf" ]]; then + while IFS= read -r _pl; do + case "$_pl" in asc-auth:*|released-store-version:*) logerr "asc-released: ${_pl}" ;; esac + done <"$errf" + fi + rm -f "$errf" >/dev/null 2>&1 || true + [[ $rc -eq 4 ]] && return 4 [[ $rc -eq 0 ]] || return 1 printf '%s' "$out" } @@ -1180,7 +1192,7 @@ else log "version: fixed marketing version ${FIXED_MARKETING} from apps.json; it does not count up per ship" # A released App Store version closes its train: a fixed version at or # below one is refused, never silently bumped. - _rel="$(asc_released_store_version "$ASC_PLATFORM" 2>/dev/null)" \ + _rel="$(asc_released_store_version "$ASC_PLATFORM")" \ || die "cannot read the released App Store version for ${BUNDLE_ID}, so fixedMarketingVersion ${FIXED_MARKETING} cannot be checked against a closed train. Nothing was shipped or bumped." if [[ -n "$_rel" ]]; then if ! python3 -c 'import sys diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index 936cc941..9dcf2072 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -1273,6 +1273,20 @@ bypass_out="$(REJECTED_KEY_FILE="${tmp}/sentinel-bypass" _rejected_key_path="${t || fail "the parser lost the label without the hand-off file: ${bypass_out}" [[ "$(cat "${tmp}/sentinel-bypass")" == "SENTINEL-BYPASS" ]] || fail "the hand-off path was taken from the environment" +# The released-version helper keeps the client's reason and its auth status: +# a discarded stderr or a flattened status 4 hides a credential fault behind +# a generic "cannot read the released version". +body="$(awk '/^asc_released_store_version\(\) \{/ {g=1} g {print} g && /^}/ {exit}' "$script")" +if grep -v '^[[:space:]]*#' <<<"$body" | grep -q '2>/dev/null'; then + fail "asc_released_store_version discards the client's stderr" +fi +grep -q 'return 4' <<<"$body" || fail "asc_released_store_version does not preserve the auth status 4" +grep -q 'released-store-version:\*' <<<"$body" || fail "asc_released_store_version does not log the client's reason" +if grep -q 'asc_released_store_version "\$ASC_PLATFORM" 2>/dev/null' "$script"; then + fail "the fixed-version caller discards the released-version reason" +fi +unset body + echo "ok: ASC credential resolution (environment first, then Infisical, a named path ignored, temp key removed)" # The version helpers run inside command substitutions, where load_secrets # would write a temp key whose cleanup trap dies with the subshell. They must