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 185e1571..a21e890f 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -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 [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., 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 = 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 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}; 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). diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 48615167..814c0a4c 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. # @@ -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 @@ -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. (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 +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 @@ -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" + # 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). @@ -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%.*}.: 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" @@ -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%.*}.. 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 +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" diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index 25ede464..9dcf2072 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -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"