Skip to content
1 change: 1 addition & 0 deletions scripts/ios-fleet/apps.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"teamId": "CC8UTF7ATG",
"apps": {
"codecaps": {
"fixedMarketingVersion": "0.1.0",
"displayName": "CodeCaps Companion",
"bundleId": "com.simplewithus.codecaps.ios",
"appleId": 6814623846,
Expand Down
142 changes: 142 additions & 0 deletions scripts/ios-fleet/asc-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2441,6 +2441,148 @@ 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 = String(body || "IOS").toUpperCase();
if (!bundleId) {
console.error("Usage: node asc-api.mjs released-store-version <bundleId> [platform=IOS]");
process.exit(2);
}
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);
}
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", "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",
"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);
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;
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"}`);
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.<n>, and the next one is M.m.<max n + 1>; 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 = String(arg4 || "IOS").toUpperCase();
const vm = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || "");
if (!bundleId || !vm) {
console.error("Usage: node asc-api.mjs next-dotted-build <bundleId> <M.m.p> [platform=IOS]");
process.exit(2);
}
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);
}
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}<n>; 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 = 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);
}

// 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).
Expand Down
100 changes: 98 additions & 2 deletions scripts/ios-fleet/ship-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -672,6 +683,48 @@ 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
# 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=$?
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
# 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
# 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>"$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"
}

asc_latest_marketing_patch() {
local prefix="$1" plat="${2:-}" out rc
if ! command -v "$ASC_NODE" >/dev/null 2>&1; then
Expand Down Expand Up @@ -995,6 +1048,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.<n> (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
Expand Down Expand Up @@ -1092,6 +1151,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
Expand Down Expand Up @@ -1120,6 +1180,29 @@ 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 [[ -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"
Comment thread
kody-ai[bot] marked this conversation as resolved.
Resolved
# 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")" \
|| 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).
Expand Down Expand Up @@ -1164,6 +1247,9 @@ else

# ---- 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%.*}.<n>: 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%.*}.<n> 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"
Expand All @@ -1174,6 +1260,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%.*}.<n>. 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"

Expand Down Expand Up @@ -1224,13 +1320,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"
Expand Down
26 changes: 26 additions & 0 deletions scripts/ios-fleet/test-asc-credentials.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1273,5 +1273,31 @@ 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
# 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"
Loading