From 7a9c4ea797e7ada3ebf872c7bd8c7c91ed5896b6 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:17:11 -0500 Subject: [PATCH 01/50] CC-169: capture iOS App Store screenshots on a runner and upload them to App Store Connect There is no iOS simulator runtime on Jay's Mac, so the App Store screenshots for the CodeCaps iOS companion have to come from a GitHub runner. The existing swift-ci companion job already builds the app and seeds a quota snapshot, but it captures at whatever size the runner's first iPhone happens to be and never asserts the result, so its images are not usable as App Store assets. This adds a dedicated capture script and workflow. The script builds the companion for one iPhone and one iPad simulator, seeds each with a realistic multi-provider quota snapshot, captures the quota list, the settings sheet and the platform-order editor, and asserts every PNG is exactly the pixel size App Store Connect accepts before the file is handed on. A screenshot with an alpha channel is flattened to JPEG, because Apple rejects transparency. The upload to App Store Connect is a separate, explicit step rather than part of the workflow, so a re-run can never quietly add duplicate screenshots to a live version. The new asc-api.mjs subcommand creates or reuses the screenshot set for a display size, reserves each screenshot, sends the bytes to the pre-signed URL Apple returns, and then commits the reservation and reports the asset delivery state. The display type is the App Store Connect enum: APP_IPHONE_67 carries the 6.9-inch iPhone size and APP_IPAD_PRO_129 the 13-inch iPad size, both verified against the live API. Refs CC-169 --- .../workflows/ios-appstore-screenshots.yml | 68 +++++ scripts/ios-fleet/asc-api.mjs | 128 +++++++++ .../ios-fleet/capture-appstore-screenshots.sh | 247 ++++++++++++++++++ 3 files changed, 443 insertions(+) create mode 100644 .github/workflows/ios-appstore-screenshots.yml create mode 100755 scripts/ios-fleet/capture-appstore-screenshots.sh diff --git a/.github/workflows/ios-appstore-screenshots.yml b/.github/workflows/ios-appstore-screenshots.yml new file mode 100644 index 00000000..ba3d5dde --- /dev/null +++ b/.github/workflows/ios-appstore-screenshots.yml @@ -0,0 +1,68 @@ +name: iOS App Store Screenshots + +# Captures the App Store screenshots for the CodeCaps iOS companion. +# +# There is no iOS simulator runtime on Jay's Mac, so the captures have to come +# from a runner. The script builds the companion for one iPhone and one iPad +# simulator, seeds each with a realistic quota snapshot and writes one PNG per +# screen at the exact pixel size App Store Connect accepts, asserting the size +# before the file is handed on. +# +# The upload to App Store Connect is deliberately NOT done here. It is a +# separate, explicit step run from a machine with the App Store Connect key: +# +# node scripts/ios-fleet/asc-api.mjs upload-screenshots \ +# APP_IPHONE_67 iphone-6.9-fleet-status.png +# +# so a re-run of this workflow can never quietly add duplicate screenshots to +# a live version. + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "ios/CodeCapsCompanion/**" + - "scripts/ios-fleet/capture-appstore-screenshots.sh" + - ".github/workflows/ios-appstore-screenshots.yml" + +concurrency: + group: ios-appstore-screenshots-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + screenshots: + name: Capture iPhone and iPad screenshots + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + + - name: Install XcodeGen + run: | + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + + - name: Capture App Store screenshots + run: | + chmod +x scripts/ios-fleet/capture-appstore-screenshots.sh + scripts/ios-fleet/capture-appstore-screenshots.sh "$RUNNER_TEMP/appstore-screenshots" + + - name: Report the captured sizes + run: | + for shot in "$RUNNER_TEMP"/appstore-screenshots/*; do + echo "::group::$(basename "$shot")" + sips -g pixelWidth -g pixelHeight -g hasAlpha "$shot" | grep -E "pixel|/sips" || true + echo "::endgroup::" + done + + - name: Upload the captured screenshots + uses: actions/upload-artifact@v7 + with: + name: appstore-screenshots + path: ${{ runner.temp }}/appstore-screenshots/* + if-no-files-found: error diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index c3cf00e9..74393083 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -357,6 +357,7 @@ async function main() { console.error("Usage: node asc-api.mjs [JSON_BODY]"); console.error(" node asc-api.mjs ensure-tf-ready [marketingVersion]"); console.error(" node asc-api.mjs latest-build-seq "); + console.error(" node asc-api.mjs upload-screenshots "); process.exit(1); } @@ -376,6 +377,133 @@ async function main() { return { status: res.status, ok: res.ok, parsed, text }; } + // ------------------------------------------------------------------------- + // App Store screenshot upload + // ------------------------------------------------------------------------- + // Usage: node asc-api.mjs upload-screenshots + // + // App Store Connect has no single-call screenshot upload. The bytes go to + // a pre-signed URL Apple hands out with the reservation, and the reservation + // is only committed once the bytes are in, so it takes three calls: + // + // 1. POST /v1/appScreenshots reserve, get the upload operations + // 2. PUT send the bytes + // 3. PATCH /v1/appScreenshots/ uploaded=true, then read + // assetDeliveryState + // + // The screenshot set (one per display size) is created on demand and reused, + // so running this twice does not create a second set. The display type is + // the App Store Connect enum, e.g. APP_IPHONE_67 for the 6.9-inch iPhone + // set and APP_IPAD_PRO_129 for the 13-inch iPad set. + if (method === "upload-screenshots") { + const localizationId = path; + const displayType = body; + const files = process.argv.slice(5); + if (!localizationId || !displayType || files.length === 0) { + console.error("Usage: node asc-api.mjs upload-screenshots "); + process.exit(2); + } + + const sets = await api("GET", `/v1/appStoreVersionLocalizations/${localizationId}/appScreenshotSets`); + if (!sets.ok) { + console.error(`upload-screenshots: could not list screenshot sets (HTTP ${sets.status})`); + process.exit(2); + } + let set = (sets.parsed.data || []).find( + (entry) => entry.attributes?.screenshotDisplayType === displayType + ); + if (!set) { + const created = await api("POST", "/v1/appScreenshotSets", JSON.stringify({ + data: { + type: "appScreenshotSets", + attributes: { screenshotDisplayType: displayType }, + relationships: { + appStoreVersionLocalization: { + data: { type: "appStoreVersionLocalizations", id: localizationId } + } + } + } + })); + if (!created.ok) { + console.error(`upload-screenshots: could not create the ${displayType} set (HTTP ${created.status})`); + console.error(created.text); + process.exit(2); + } + set = created.parsed.data; + console.error(`upload-screenshots: created the ${displayType} set ${set.id}`); + } else { + console.error(`upload-screenshots: reusing the ${displayType} set ${set.id}`); + } + + for (const file of files) { + if (!existsSync(file)) { + console.error(`upload-screenshots: no such file: ${file}`); + process.exit(2); + } + const bytes = readFileSync(file); + const fileName = file.split("/").pop(); + + const reserved = await api("POST", "/v1/appScreenshots", JSON.stringify({ + data: { + type: "appScreenshots", + attributes: { fileName, fileSize: bytes.length }, + relationships: { + appScreenshotSet: { data: { type: "appScreenshotSets", id: set.id } } + } + } + })); + if (!reserved.ok) { + console.error(`upload-screenshots: could not reserve ${fileName} (HTTP ${reserved.status})`); + console.error(reserved.text); + process.exit(2); + } + const screenshot = reserved.parsed.data; + const operations = screenshot.attributes?.uploadOperations || []; + if (operations.length === 0) { + console.error(`upload-screenshots: Apple returned no upload operation for ${fileName}`); + process.exit(2); + } + + for (const operation of operations) { + const headers = {}; + for (const header of operation.requestHeaders || []) headers[header.name] = header.value; + const offset = operation.offset || 0; + const length = operation.length || bytes.length; + const chunk = bytes.subarray(offset, offset + length); + const put = await fetch(operation.url, { + method: operation.method || "PUT", + headers, + body: chunk + }); + if (!put.ok) { + console.error(`upload-screenshots: upload of ${fileName} failed (HTTP ${put.status})`); + process.exit(2); + } + } + + const committed = await api("PATCH", `/v1/appScreenshots/${screenshot.id}`, JSON.stringify({ + data: { type: "appScreenshots", id: screenshot.id, attributes: { uploaded: true } } + })); + if (!committed.ok) { + console.error(`upload-screenshots: could not commit ${fileName} (HTTP ${committed.status})`); + console.error(committed.text); + process.exit(2); + } + const state = committed.parsed.data?.attributes?.assetDeliveryState; + const stateName = state?.state || "UNKNOWN"; + console.error(`upload-screenshots: ${fileName} -> ${screenshot.id} (${stateName})`); + if (state?.errors?.length) { + console.error(`upload-screenshots: Apple rejected ${fileName}:`); + for (const problem of state.errors) { + console.error(` ${problem.code}: ${problem.title} ${problem.detail || ""}`); + } + process.exit(3); + } + } + console.log(JSON.stringify({ ok: true, setId: set.id, displayType, files })); + process.exit(0); + } + // Highest N already used by the ".N" MARKETING version train // (e.g. 1.0.7). This is what makes App Store Connect -- not a single unbacked // local file -- the source of truth for "what has already shipped", so a diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh new file mode 100755 index 00000000..f3f5ae6f --- /dev/null +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# Capture App Store screenshots for the CodeCaps iOS companion. +# +# Runs on a macOS runner that has an iOS simulator runtime. Builds the +# companion for one iPhone and one iPad simulator, seeds each simulator with a +# realistic quota snapshot, and writes one PNG per screen at the exact pixel +# size App Store Connect accepts. The size is asserted before the file is +# handed on, so a mis-sized capture fails here instead of at upload time. +# +# The companion reads its readings from +# /Library/Application Support/CodeCaps/quota-windows.json +# (see CompanionQuotaModel.loadLocalFallback), so seeding that file is enough +# to put real-looking quota cards on screen without a live sync endpoint. +# +# Usage: scripts/ios-fleet/capture-appstore-screenshots.sh [OUTPUT_DIR] +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROJECT_DIR="$REPO_ROOT/ios/CodeCapsCompanion" +SCHEME="CodeCapsCompanion" +BUNDLE_ID="com.simplewithus.codecaps.ios" +OUT_DIR="${1:-${REPO_ROOT}/.build/appstore-screenshots}" +DERIVED_ROOT="${DERIVED_ROOT:-${RUNNER_TEMP:-/tmp}/codecaps-appstore-derived}" + +mkdir -p "$OUT_DIR" + +# App Store Connect accepts exactly these display sizes. The capture asserts +# the produced pixel size against the one paired with the simulator, so a +# simulator that renders at an unexpected size fails loudly instead of +# uploading a screenshot Apple rejects. +# +# iPhone 6.9" 1320 x 2868 (iPhone 16 Pro Max and friends) +# iPhone 6.7" 1290 x 2796 (iPhone 16 Plus and friends) +# iPad 13" 2064 x 2752 (iPad Pro 13-inch, M4) +# iPad 12.9" 2048 x 2732 (iPad Pro 12.9-inch, 6th generation) +IPHONE_PREFERRED="iPhone 16 Pro Max|iPhone 15 Pro Max|iPhone 14 Pro Max|iPhone 16 Plus|iPhone 15 Plus|iPhone 14 Plus" +IPAD_PREFERRED="iPad Pro 13-inch (M4)|iPad Pro 13-inch (M5)|iPad Pro 12.9-inch (6th generation)|iPad Pro 12.9-inch (5th generation)" + +log() { echo "capture: $*"; } +fail() { echo "::error::capture: $*" >&2; exit 1; } + +# Print the UDID of the first available simulator whose display name matches +# one of the pipe-separated preferred names, in order. +pick_simulator() { + python3 - "$1" <<'PY' +import json, subprocess, sys + +preferred = sys.argv[1].split("|") +raw = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "-j"], + capture_output=True, text=True, check=True, +).stdout +data = json.loads(raw) +devices = [ + d + for group in data.get("devices", {}).values() + for d in group + if d.get("isAvailable") +] +for want in preferred: + for device in devices: + if device.get("name") == want: + print(device["udid"]) + sys.exit(0) +sys.exit(1) +PY +} + +# Write a realistic quota snapshot into the simulator's app container. +seed_snapshot() { + local container="$1" + python3 - "$container" <<'PY' +import datetime, json, os, sys + +container = sys.argv[1] +target = os.path.join(container, "Library", "Application Support", "CodeCaps", "quota-windows.json") +os.makedirs(os.path.dirname(target), exist_ok=True) + +now = datetime.datetime.now(datetime.timezone.utc) +observed = now.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def reset_in(hours, minutes=0): + when = now + datetime.timedelta(hours=hours, minutes=minutes) + return when.isoformat(timespec="seconds").replace("+00:00", "Z") + + +# (id, provider, providerKey, providerLabel, label, window token, percent, reset offset) +providers = [ + ("anthropic-5h", "anthropic", "anthropic", "Claude Code", "5-hour window", "5h", 72.0, (3, 40)), + ("anthropic-7d", "anthropic", "anthropic", "Claude Code", "7-day window", "7d", 44.0, (86, 0)), + ("openai-weekly", "openai", "openai", "Codex", "7-day window", "7d", 61.0, (41, 15)), + ("cursor-fast", "cursor", "cursor", "Cursor", "Fast Requests window", "fast", 28.0, (1, 12)), + ("cursor-total", "cursor", "cursor", "Cursor", "Total Requests window", "total", 83.0, (13, 5)), + ("xai-monthly", "xai", "xai", "xAI", "1-month window", "1m", 55.0, (212, 0)), + ("minimax-5h", "minimax", "minimax", "minimax", "5-hour window", "5h", 38.0, (2, 20)), + ("antigravity-daily", "google-antigravity", "google-antigravity", "Antigravity", "1-day window", "1d", 91.0, (9, 45)), +] + +windows = [] +for (wid, provider, key, _label, label, token, percent, (hours, minutes)) in providers: + windows.append({ + "id": wid, + "provider": provider, + "providerKey": key, + "label": label, + "window": token, + "remainingPercent": percent, + "isExhausted": False, + "resetAt": reset_in(hours, minutes), + "occurredAt": observed, + "source": "studio-mac", + "via": "oauth", + }) + +groups = [] +for index, (_wid, provider, key, provider_label, _label, _token, _percent, _offset) in enumerate(providers): + if any(group["provider"] == provider for group in groups): + continue + groups.append({ + "provider": provider, + "providerKey": key, + "providerLabel": provider_label, + "via": "oauth", + "sortOrder": index, + }) + +with open(target, "w", encoding="utf-8") as snapshot: + json.dump({"windows": windows, "providerGroups": groups}, snapshot, indent=2) +print(f"seeded {len(windows)} windows across {len(groups)} platforms -> {target}") +PY +} + +# Assert the PNG is exactly the pixel size App Store Connect accepts for the +# paired display size, and that it carries no alpha channel (Apple rejects +# screenshots with transparency). +assert_screenshot() { + local file="$1" want_w="$2" want_h="$3" + local width height alpha + width="$(sips -g pixelWidth "$file" | awk '/pixelWidth/ {print $2}')" + height="$(sips -g pixelHeight "$file" | awk '/pixelHeight/ {print $2}')" + alpha="$(sips -g hasAlpha "$file" | awk '/hasAlpha/ {print $2}')" + if [ "$width" != "$want_w" ] || [ "$height" != "$want_h" ]; then + fail "$(basename "$file") is ${width}x${height}; App Store Connect wants ${want_w}x${want_h}" + fi + if [ "$alpha" = "yes" ]; then + # Flatten to JPEG, which cannot carry an alpha channel, and use that file. + local flattened="${file%.png}.jpg" + sips -s format jpeg -s formatOptions 92 "$file" --out "$flattened" >/dev/null + rm -f "$file" + log "flattened $(basename "$flattened") (the PNG carried an alpha channel)" + echo "$flattened" + return + fi + echo "$file" +} + +# Build, install, seed, launch and capture on one simulator. +capture_on() { + local udid="$1" label="$2" want_w="$3" want_h="$4" + local derived="$DERIVED_ROOT/$label" + local app="$derived/Build/Products/Debug-iphonesimulator/CodeCapsCompanion.app" + local container bundle_id + + log "booting $label ($udid)" + xcrun simctl boot "$udid" 2>/dev/null || true + xcrun simctl bootstatus "$udid" -b >/dev/null + + log "building $label" + ( cd "$PROJECT_DIR" && xcodebuild -project CodeCapsCompanion.xcodeproj \ + -scheme "$SCHEME" \ + -destination "platform=iOS Simulator,id=$udid" \ + -derivedDataPath "$derived" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY="" \ + build >/dev/null ) + [ -d "$app" ] || fail "no app built at $app" + + bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Info.plist")" + log "installing $bundle_id" + xcrun simctl install "$udid" "$app" + + container="$(xcrun simctl get_app_container "$udid" "$bundle_id" data)" + seed_snapshot "$container" + + # Three screens per device: the quota list, the settings sheet, and the + # platform-order editor. The settings sheet and the editor are opened + # with the launch arguments the app itself understands. + local shot + shot="$(xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-fleet-status.png" 2>/dev/null)" || true + [ -s "$OUT_DIR/$label-fleet-status.png" ] || fail "no screenshot for $label fleet-status" + + xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" -openSettings >/dev/null + sleep 4 + xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-settings.png" >/dev/null 2>&1 || true + [ -s "$OUT_DIR/$label-settings.png" ] || fail "no screenshot for $label settings" + + xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" -reorderPlatforms >/dev/null + sleep 4 + xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-platform-order.png" >/dev/null 2>&1 || true + [ -s "$OUT_DIR/$label-platform-order.png" ] || fail "no screenshot for $label platform-order" + + xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + + local name + for name in fleet-status settings platform-order; do + local candidate="$OUT_DIR/$label-$name.png" + local checked + checked="$(assert_screenshot "$candidate" "$want_w" "$want_h")" + if [ "$checked" != "$candidate" ]; then + mv "$checked" "$OUT_DIR/$label-$name.jpg" + rm -f "$candidate" + checked="$OUT_DIR/$label-$name.jpg" + fi + log "$label $name -> $(basename "$checked")" + done +} + +main() { + command -v xcodegen >/dev/null 2>&1 || fail "xcodegen is not installed" + if [ ! -f "$PROJECT_DIR/CodeCapsCompanion.xcodeproj/project.pbxproj" ] \ + || [ "$PROJECT_DIR/project.yml" -nt "$PROJECT_DIR/CodeCapsCompanion.xcodeproj/project.pbxproj" ]; then + log "regenerating the companion project" + ( cd "$PROJECT_DIR" && xcodegen generate ) + fi + + local iphone_udid ipad_udid + if ! iphone_udid="$(pick_simulator "$IPHONE_PREFERRED")"; then + fail "no preferred iPhone simulator on this runner" + fi + if ! ipad_udid="$(pick_simulator "$IPAD_PREFERRED")"; then + fail "no preferred iPad simulator on this runner" + fi + + log "iPhone simulator $iphone_udid" + log "iPad simulator $ipad_udid" + + capture_on "$iphone_udid" "iphone-6.9" 1320 2868 + capture_on "$ipad_udid" "ipad-13" 2064 2752 + + log "captured:" + ls -1 "$OUT_DIR" +} + +main "$@" From cdd9dfc772b815c4cd6342a59729031a96d87f35 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:32:01 -0500 Subject: [PATCH 02/50] CC-169: keep the seeded App Store snapshot to platforms that render as one card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seeded windows are the screens App Store reviewers will see, so each provider has to land on a single clean card. Antigravity is dropped: the app pools its windows into per-model sections rather than one platform card, so it would have read as "Antigravity ยท Third-Party" in the listing screenshot. Cursor's two allowances become one 1-month window, because a second window of the same cadence is folded in as a duplicate source instead of getting its own row, and the window tokens are now only the ones QuotaWindowVocabulary recognises, so every card shows a clean cadence caption. Refs CC-169 --- .../ios-fleet/capture-appstore-screenshots.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index f3f5ae6f..ccbb5592 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -87,15 +87,21 @@ def reset_in(hours, minutes=0): # (id, provider, providerKey, providerLabel, label, window token, percent, reset offset) +# +# The window tokens are the ones QuotaWindowVocabulary.cadenceFromToken +# recognises ("5h", "7d", "1d", "1m"), so each card shows a clean cadence +# caption. Two windows of the same provider must have different cadences: +# a second window of the same cadence is folded in as a duplicate source +# rather than shown as its own row. Antigravity is left out on purpose, as +# the app pools its windows into per-model sections and it would not read as +# one platform card. providers = [ ("anthropic-5h", "anthropic", "anthropic", "Claude Code", "5-hour window", "5h", 72.0, (3, 40)), - ("anthropic-7d", "anthropic", "anthropic", "Claude Code", "7-day window", "7d", 44.0, (86, 0)), + ("anthropic-7d", "anthropic", "anthropic", "Claude Code", "7-day window", "7d", 78.0, (86, 0)), ("openai-weekly", "openai", "openai", "Codex", "7-day window", "7d", 61.0, (41, 15)), - ("cursor-fast", "cursor", "cursor", "Cursor", "Fast Requests window", "fast", 28.0, (1, 12)), - ("cursor-total", "cursor", "cursor", "Cursor", "Total Requests window", "total", 83.0, (13, 5)), + ("cursor-monthly", "cursor", "cursor", "Cursor", "1-month window", "1m", 34.0, (313, 0)), ("xai-monthly", "xai", "xai", "xAI", "1-month window", "1m", 55.0, (212, 0)), - ("minimax-5h", "minimax", "minimax", "minimax", "5-hour window", "5h", 38.0, (2, 20)), - ("antigravity-daily", "google-antigravity", "google-antigravity", "Antigravity", "1-day window", "1d", 91.0, (9, 45)), + ("minimax-5h", "minimax", "minimax", "MiniMax", "5-hour window", "5h", 38.0, (2, 20)), ] windows = [] From 5a70093154fbebb2a66a6526d4316c4ccbb275b8 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:35:06 -0500 Subject: [PATCH 03/50] CC-169: fix the capture script bugs the review bots found Four defects, all of which would have wasted a capture run: - The first screenshot was taken before the app was ever launched, so it recorded the simulator home screen. The app is now launched before every capture, with the launch argument that opens the screen being shot. - assert_screenshot logged to stdout, and its stdout is what the caller captures to learn the accepted file path, so the log line became part of the path and the following mv failed. log() now writes to stderr. - The expected pixel size was hardcoded to 1320x2868 and 2064x2752 while the picker could fall back to a device that renders at another size, so the assertion would fail on a runner without the first choice. The size is now measured from the capture, every file has to agree on it, and it has to be a size App Store Connect accepts for its display class. The output files are named for the class they actually are. - The upload step could be given the wrong display type by hand, and there is no APP_IPHONE_69 or APP_IPAD_PRO_13 in the enum to be caught by. The upload now takes "auto" and reads the pixel size out of the PNG to pick the set: 1320x2868 and 1290x2796 go to APP_IPHONE_67, 2064x2752 to APP_IPAD_PRO_129, 2048x2732 to APP_IPAD_PRO_3GEN_129. Also stops the workflow cancelling a main-branch run for a commit that nothing replaced, which reported cancelled checks to anything polling the workflow API. Refs CC-169 --- .../workflows/ios-appstore-screenshots.yml | 4 +- scripts/ios-fleet/asc-api.mjs | 35 ++++- .../ios-fleet/capture-appstore-screenshots.sh | 138 +++++++++++------- 3 files changed, 122 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ios-appstore-screenshots.yml b/.github/workflows/ios-appstore-screenshots.yml index ba3d5dde..c07a92dd 100644 --- a/.github/workflows/ios-appstore-screenshots.yml +++ b/.github/workflows/ios-appstore-screenshots.yml @@ -28,7 +28,9 @@ on: concurrency: group: ios-appstore-screenshots-${{ github.ref }} - cancel-in-progress: true + # Only supersede a run for the same pull request. Cancelling on a push to + # main would report cancelled checks for a commit that nothing replaced. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 74393083..a83b6307 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -397,13 +397,44 @@ async function main() { // set and APP_IPAD_PRO_129 for the 13-inch iPad set. if (method === "upload-screenshots") { const localizationId = path; - const displayType = body; + let displayType = body; const files = process.argv.slice(5); if (!localizationId || !displayType || files.length === 0) { - console.error("Usage: node asc-api.mjs upload-screenshots "); + console.error("Usage: node asc-api.mjs upload-screenshots "); process.exit(2); } + // "auto" reads the pixel size out of the PNG and picks the screenshot set + // that size belongs to, so the display type cannot be mis-specified. + // App Store Connect has no APP_IPHONE_69 or APP_IPAD_PRO_13: the 6.9-inch + // iPhone size lives in APP_IPHONE_67 and the 13-inch iPad size in + // APP_IPAD_PRO_129. + const sizeToDisplayType = { + "1320x2868": "APP_IPHONE_67", + "1290x2796": "APP_IPHONE_67", + "1284x2778": "APP_IPHONE_65", + "1242x2688": "APP_IPHONE_65", + "2064x2752": "APP_IPAD_PRO_129", + "2048x2732": "APP_IPAD_PRO_3GEN_129", + "2000x2664": "APP_IPAD_PRO_3GEN_129" + }; + if (displayType === "auto") { + if (!existsSync(files[0])) { + console.error(`upload-screenshots: no such file: ${files[0]}`); + process.exit(2); + } + const header = readFileSync(files[0]).subarray(0, 24); + const width = header.readUInt32BE(16); + const height = header.readUInt32BE(20); + const size = `${width}x${height}`; + displayType = sizeToDisplayType[size]; + if (!displayType) { + console.error(`upload-screenshots: ${size} is not a size App Store Connect accepts`); + process.exit(2); + } + console.error(`upload-screenshots: ${size} -> ${displayType}`); + } + const sets = await api("GET", `/v1/appStoreVersionLocalizations/${localizationId}/appScreenshotSets`); if (!sets.ok) { console.error(`upload-screenshots: could not list screenshot sets (HTTP ${sets.status})`); diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index ccbb5592..35f585e5 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -5,39 +5,49 @@ # Runs on a macOS runner that has an iOS simulator runtime. Builds the # companion for one iPhone and one iPad simulator, seeds each simulator with a # realistic quota snapshot, and writes one PNG per screen at the exact pixel -# size App Store Connect accepts. The size is asserted before the file is -# handed on, so a mis-sized capture fails here instead of at upload time. +# size App Store Connect accepts. # # The companion reads its readings from # /Library/Application Support/CodeCaps/quota-windows.json # (see CompanionQuotaModel.loadLocalFallback), so seeding that file is enough # to put real-looking quota cards on screen without a live sync endpoint. # +# The pixel size is measured from the capture rather than assumed, and every +# file has to land on a size App Store Connect accepts for its display class. +# A simulator whose native size Apple does not accept fails the run here +# instead of producing a file that is rejected at upload time. +# # Usage: scripts/ios-fleet/capture-appstore-screenshots.sh [OUTPUT_DIR] set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PROJECT_DIR="$REPO_ROOT/ios/CodeCapsCompanion" SCHEME="CodeCapsCompanion" -BUNDLE_ID="com.simplewithus.codecaps.ios" OUT_DIR="${1:-${REPO_ROOT}/.build/appstore-screenshots}" DERIVED_ROOT="${DERIVED_ROOT:-${RUNNER_TEMP:-/tmp}/codecaps-appstore-derived}" mkdir -p "$OUT_DIR" -# App Store Connect accepts exactly these display sizes. The capture asserts -# the produced pixel size against the one paired with the simulator, so a -# simulator that renders at an unexpected size fails loudly instead of -# uploading a screenshot Apple rejects. +# App Store Connect accepts these display sizes: # # iPhone 6.9" 1320 x 2868 (iPhone 16 Pro Max and friends) # iPhone 6.7" 1290 x 2796 (iPhone 16 Plus and friends) -# iPad 13" 2064 x 2752 (iPad Pro 13-inch, M4) +# iPhone 6.5" 1284 x 2778 (iPhone 13 Pro Max and friends) +# iPhone 6.5" 1242 x 2688 (iPhone 12 Pro Max and friends) +# iPad 13" 2064 x 2752 (iPad Pro 13-inch) # iPad 12.9" 2048 x 2732 (iPad Pro 12.9-inch, 6th generation) -IPHONE_PREFERRED="iPhone 16 Pro Max|iPhone 15 Pro Max|iPhone 14 Pro Max|iPhone 16 Plus|iPhone 15 Plus|iPhone 14 Plus" -IPAD_PREFERRED="iPad Pro 13-inch (M4)|iPad Pro 13-inch (M5)|iPad Pro 12.9-inch (6th generation)|iPad Pro 12.9-inch (5th generation)" - -log() { echo "capture: $*"; } +# iPad 12.9" 2000 x 2664 (iPad Pro 12.9-inch, 2nd generation) +# +# The first entry for each display class is the one the App Store Connect +# screenshot set expects, and the upload step maps a captured file to its set +# from these numbers. +IPHONE_PREFERRED="iPhone 17 Pro Max|iPhone 16 Pro Max|iPhone 15 Pro Max|iPhone 14 Pro Max|iPhone 17 Plus|iPhone 16 Plus|iPhone 15 Plus|iPhone 14 Plus|iPhone 13 Pro Max|iPhone 12 Pro Max" +IPAD_PREFERRED="iPad Pro 13-inch (M5)|iPad Pro 13-inch (M4)|iPad Pro 12.9-inch (6th generation)|iPad Pro 12.9-inch (5th generation)|iPad Pro 12.9-inch (3rd generation)" + +# log() writes to stderr on purpose: its output is captured by command +# substitution in assert_screenshot, and anything on stdout would be read +# back as part of the returned file path. +log() { echo "capture: $*" >&2; } fail() { echo "::error::capture: $*" >&2; exit 1; } # Print the UDID of the first available simulator whose display name matches @@ -67,6 +77,20 @@ sys.exit(1) PY } +# Print "x" for a PNG, or nothing if it cannot be read. +png_size() { + sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null \ + | awk '/pixelWidth/ {w = $2} /pixelHeight/ {h = $2} END {if (w && h) print w "x" h}' +} + +# Print the App Store Connect display class for a pixel size, or nothing. +display_class_for_size() { + case "$1" in + 1320x2868|1290x2796|1284x2778|1242x2688) echo "iphone" ;; + 2064x2752|2048x2732|2000x2664) echo "ipad" ;; + esac +} + # Write a realistic quota snapshot into the simulator's app container. seed_snapshot() { local container="$1" @@ -138,21 +162,27 @@ print(f"seeded {len(windows)} windows across {len(groups)} platforms -> {target} PY } -# Assert the PNG is exactly the pixel size App Store Connect accepts for the -# paired display size, and that it carries no alpha channel (Apple rejects -# screenshots with transparency). +# Check a captured PNG against the size App Store Connect accepts for its +# display class, and flatten it if it carries an alpha channel, which Apple +# rejects. The accepted file path is printed on stdout; every log line goes +# to stderr so the caller can capture the path cleanly. assert_screenshot() { - local file="$1" want_w="$2" want_h="$3" - local width height alpha - width="$(sips -g pixelWidth "$file" | awk '/pixelWidth/ {print $2}')" - height="$(sips -g pixelHeight "$file" | awk '/pixelHeight/ {print $2}')" - alpha="$(sips -g hasAlpha "$file" | awk '/hasAlpha/ {print $2}')" - if [ "$width" != "$want_w" ] || [ "$height" != "$want_h" ]; then - fail "$(basename "$file") is ${width}x${height}; App Store Connect wants ${want_w}x${want_h}" + local file="$1" want="$2" + local size alpha flattened + size="$(png_size "$file")" + if [ -z "$size" ]; then + fail "$(basename "$file") could not be read" + fi + if [ "$size" != "$want" ]; then + fail "$(basename "$file") is $size, but the other captures are $want" + fi + if [ -z "$(display_class_for_size "$size")" ]; then + fail "$(basename "$file") is $size, which App Store Connect does not accept for any display class" fi + alpha="$(sips -g hasAlpha "$file" 2>/dev/null | awk '/hasAlpha/ {print $2}')" if [ "$alpha" = "yes" ]; then # Flatten to JPEG, which cannot carry an alpha channel, and use that file. - local flattened="${file%.png}.jpg" + flattened="${file%.png}.jpg" sips -s format jpeg -s formatOptions 92 "$file" --out "$flattened" >/dev/null rm -f "$file" log "flattened $(basename "$flattened") (the PNG carried an alpha channel)" @@ -164,7 +194,7 @@ assert_screenshot() { # Build, install, seed, launch and capture on one simulator. capture_on() { - local udid="$1" label="$2" want_w="$3" want_h="$4" + local udid="$1" label="$2" kind="$3" local derived="$DERIVED_ROOT/$label" local app="$derived/Build/Products/Debug-iphonesimulator/CodeCapsCompanion.app" local container bundle_id @@ -190,37 +220,41 @@ capture_on() { seed_snapshot "$container" # Three screens per device: the quota list, the settings sheet, and the - # platform-order editor. The settings sheet and the editor are opened - # with the launch arguments the app itself understands. - local shot - shot="$(xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-fleet-status.png" 2>/dev/null)" || true - [ -s "$OUT_DIR/$label-fleet-status.png" ] || fail "no screenshot for $label fleet-status" - - xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true - xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" -openSettings >/dev/null - sleep 4 - xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-settings.png" >/dev/null 2>&1 || true - [ -s "$OUT_DIR/$label-settings.png" ] || fail "no screenshot for $label settings" - + # platform-order editor. The settings sheet and the editor are opened with + # the launch arguments the app itself understands. The app is launched + # before every capture, so no shot is ever the home screen. + local name + for name in fleet-status settings platform-order; do + local args=() + case "$name" in + settings) args=(-openSettings) ;; + platform-order) args=(-reorderPlatforms) ;; + esac + xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" "${args[@]}" >/dev/null + sleep 4 + xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-$name.png" >/dev/null 2>&1 || true + [ -s "$OUT_DIR/$label-$name.png" ] || fail "no screenshot for $label $name" + done xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true - xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" -reorderPlatforms >/dev/null - sleep 4 - xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-platform-order.png" >/dev/null 2>&1 || true - [ -s "$OUT_DIR/$label-platform-order.png" ] || fail "no screenshot for $label platform-order" - xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + # The captured size decides the display class and the file name, so the + # output says what the pixels actually are. + local size class_name + size="$(png_size "$OUT_DIR/$label-fleet-status.png")" + class_name="$(display_class_for_size "$size")" + [ -n "$class_name" ] || fail "$label rendered at $size, which App Store Connect does not accept" - local name + local checked for name in fleet-status settings platform-order; do - local candidate="$OUT_DIR/$label-$name.png" - local checked - checked="$(assert_screenshot "$candidate" "$want_w" "$want_h")" - if [ "$checked" != "$candidate" ]; then - mv "$checked" "$OUT_DIR/$label-$name.jpg" - rm -f "$candidate" - checked="$OUT_DIR/$label-$name.jpg" + checked="$(assert_screenshot "$OUT_DIR/$label-$name.png" "$size")" + if [ "$checked" != "$OUT_DIR/$label-$name.png" ]; then + mv "$checked" "$OUT_DIR/$class_name-$name.jpg" + rm -f "$OUT_DIR/$label-$name.png" + else + mv "$checked" "$OUT_DIR/$class_name-$name.png" fi - log "$label $name -> $(basename "$checked")" + log "$class_name $name ($size)" done } @@ -243,8 +277,8 @@ main() { log "iPhone simulator $iphone_udid" log "iPad simulator $ipad_udid" - capture_on "$iphone_udid" "iphone-6.9" 1320 2868 - capture_on "$ipad_udid" "ipad-13" 2064 2752 + capture_on "$iphone_udid" "iphone" "iphone" + capture_on "$ipad_udid" "ipad" "ipad" log "captured:" ls -1 "$OUT_DIR" From 34c9b5f8a3234adac5df1180f6bf86a32347686c Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:43:44 -0500 Subject: [PATCH 04/50] CC-169: fail the capture when the pixels are not the family we booted The capture script took a device family argument and never used it, so a simulator that rendered at the other family's size would have been captured, named and uploaded into the wrong screenshot set without a word. The display class is already derived from the measured pixels, so the check is a comparison of two things that are both already known. It fails the run instead of quietly producing a mismatched set. --- .github/workflows/ios-appstore-screenshots.yml | 2 +- scripts/ios-fleet/capture-appstore-screenshots.sh | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios-appstore-screenshots.yml b/.github/workflows/ios-appstore-screenshots.yml index c07a92dd..14cb0611 100644 --- a/.github/workflows/ios-appstore-screenshots.yml +++ b/.github/workflows/ios-appstore-screenshots.yml @@ -12,7 +12,7 @@ name: iOS App Store Screenshots # separate, explicit step run from a machine with the App Store Connect key: # # node scripts/ios-fleet/asc-api.mjs upload-screenshots \ -# APP_IPHONE_67 iphone-6.9-fleet-status.png +# auto iphone-fleet-status.png ipad-fleet-status.png # # so a re-run of this workflow can never quietly add duplicate screenshots to # a live version. diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index 35f585e5..3e26c246 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -244,6 +244,10 @@ capture_on() { size="$(png_size "$OUT_DIR/$label-fleet-status.png")" class_name="$(display_class_for_size "$size")" [ -n "$class_name" ] || fail "$label rendered at $size, which App Store Connect does not accept" + # The family we booted has to be the family the pixels are, or the two + # captures would be uploaded into the wrong screenshot sets. + [ "$class_name" = "$kind" ] \ + || fail "$label rendered at $size, which is a $class_name capture, not a $kind one" local checked for name in fleet-status settings platform-order; do From 9c82aaea761a2e3472773701798005070ead966f Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:45:55 -0500 Subject: [PATCH 05/50] CC-169: find the iPad simulator by prefix, not by exact name Xcode names some iPads with their storage variant, so "iPad Pro 13-inch (M5)" never matches a device called "iPad Pro 13-inch (M5) (16GB)". On a runner that only has the current runtime there is no other iPad to fall back to, so the capture would have found no iPad at all and failed after an hour of runner time, having already spent the time. The picker moves into its own module so it can be tested, and it matches on a prefix so the storage variant does not hide the device. The tests cover the Xcode 27 names, the preference order, and a device that is not available. --- .github/workflows/ios-ship-contract.yml | 1 + .../pick_simulator.cpython-314.pyc | Bin 0 -> 3066 bytes .../ios-fleet/capture-appstore-screenshots.sh | 29 ++------ scripts/ios-fleet/pick_simulator.py | 63 ++++++++++++++++ scripts/ios-fleet/test_pick_simulator.py | 67 ++++++++++++++++++ 5 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 scripts/ios-fleet/__pycache__/pick_simulator.cpython-314.pyc create mode 100644 scripts/ios-fleet/pick_simulator.py create mode 100644 scripts/ios-fleet/test_pick_simulator.py diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index da8270af..aa90cb2e 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -45,3 +45,4 @@ jobs: node --test scripts/ios-ship-workflow.test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs + python3 scripts/ios-fleet/test_pick_simulator.py diff --git a/scripts/ios-fleet/__pycache__/pick_simulator.cpython-314.pyc b/scripts/ios-fleet/__pycache__/pick_simulator.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd48f845c5bdba5504e90c47f41f195b9edb5a32 GIT binary patch literal 3066 zcmaJ@O>7&-6`uVgDN>ZE)RAmOvBy@Th_FT3h-xQ}twgb_SXH7Bj78)in;tKR;@ZpI z^~|jPPy}k)qOya+3Q*Jm+C$w-lS5JW)Etvza*8VTNirxHiS{qdXPwzbm)^M1Nzjtwo>ASw-Ks7i%=grXB2leO>DpSqjM>EjAHlp zSW_BNBB}Y_2bTN8C!ZuMDndnOIRcA1#nY!JPGh$g$~B8|fyo@PY;w`2*n0CV%&mrR z6X7!4Ai}I$&MYR_W3)yYqgCvX1}&u1XF-!mJmE8nxydX~V29Ew$F2ivigo$g)8t@>&K`Re%72wgc)jA`Cl0YXV>lK#1PcouZ@p~hY(>zwvz7J#VfB!->O=C&r$4kj z`BH?Sr1yuTzUrwK${S1nC{ait=dr2y{|Wb)jJy!7;1?v}WYB7m#UZ_A#+ky#jCw9m z_9c9wl_KF}63v28Nn-P~!v zcVGZx9MIviYx@9x_(uysKZn@EuM-FUo`QTjMLa&-upFyF7KSXB4-wCkX#{1!WKdRk z-4%Q|s$zUtPN>je8x!oyAImeKtV^ju*rH+ggR zgYm!i{-yWhH|`wy@r~})?whmA6JO*e@8e2@Tzdx|n`Rtv} z!?!yR-`aJj^XPi#(T$#`f1TYjP!GPZp|0NhDb$|({y_$Hy{>S168&QL*g^dd3FxHN zR!f2eJT-X?-X@oMG|kepEmu{Y|B$hCl6?9(^dr@77`vJTxQoGHgXS0C%?i zkf+UD@RYJr%!_F02tzCM}?P{^9RlpF@;7FZi#Ih)slj<~i}Twi#;2y{Ug zL|~Y8YF^GK0%O61!WJYVg@G>5k#H>o+-ON1x|B}>xBZJAb4|*5pheR~74!xACGI+b zVY^V>nG9W!m?O4Nd0vN!WIoWVM3BIkWv=fr=}|62160Qh_K$*gX)op>XBdW_frnp) zZW(=(-gUijtq`QfHhP}g$aZaXJn?OtkxBe3jgr})*6wAHk@~hBcy;~wwd1SidoM4a z-oVfNF?BQb;htNsuHj>M@aSzkx{hC2&rILOudMo?`=6Arjeq7}9f!Yv%S?ZvPk+Cb zndXuipW-LRjwZ~9OS2pXGAdi&gm`9J-?pi8UVUL6?mK&Bsh)wkQ7b7ka-U%aLL$FI`D~BJsS$E?9ik4kWn}o^_peVK>10a`&Fw7 z^wDezB=O|cEztNP2et{Q%PcHBVbBAEdvJk(TB;K^I~OrUA{mHzqBn@;FgTPD9CnD8 zp<70q$@I#|&lXq5e*Vs8D!bAC+Gf|T8@=!MerITHX(%|kuFY)k?@s)7^uv)`%Abz? z`Q%3@KmNhm!INv*(dF@tWcJ;6zUs_v>TN3ntKGjUtm}Ke%Cv8RW)!3<6=|?i$+ri| zN~P+Wl}eDVRBFDwDOM^>Vq-EWtVec(vXf;r+)ap6>X06Sg*siZ1Um?yKSFFs&I-Ao zIstb>-=?EXhFZptPeON3QxxUCo>BDgb|a<#Ym~W*y6&R%gG9TMd$6|+0{dWhM#>1Lr?zl%)4i&2; exit 1; } # Print the UDID of the first available simulator whose display name matches # one of the pipe-separated preferred names, in order. +# +# The picker matches on a prefix, because Xcode names some iPads with their +# storage variant ("iPad Pro 13-inch (M5) (16GB)"), and an exact match would +# find no iPad at all on a current runner. Its tests live next to it in +# scripts/ios-fleet/test_pick_simulator.py. pick_simulator() { - python3 - "$1" <<'PY' -import json, subprocess, sys - -preferred = sys.argv[1].split("|") -raw = subprocess.run( - ["xcrun", "simctl", "list", "devices", "available", "-j"], - capture_output=True, text=True, check=True, -).stdout -data = json.loads(raw) -devices = [ - d - for group in data.get("devices", {}).values() - for d in group - if d.get("isAvailable") -] -for want in preferred: - for device in devices: - if device.get("name") == want: - print(device["udid"]) - sys.exit(0) -sys.exit(1) -PY + python3 "$SCRIPT_DIR/pick_simulator.py" "$1" } # Print "x" for a PNG, or nothing if it cannot be read. diff --git a/scripts/ios-fleet/pick_simulator.py b/scripts/ios-fleet/pick_simulator.py new file mode 100644 index 00000000..8e04b885 --- /dev/null +++ b/scripts/ios-fleet/pick_simulator.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Print the UDID of the first available iOS simulator matching a preferred name. + +The capture script needs one iPhone simulator and one iPad simulator, and it +needs them to be the models whose screen size App Store Connect accepts. The +preferred names are given as a pipe-separated list, most wanted first, and the +first device whose name starts with one of them wins. + +The match is a prefix rather than an equality because Xcode names some iPads +with their storage variant ("iPad Pro 13-inch (M5) (16GB)"). An exact match +finds no iPad at all on a runner that only has the current runtime, and the +capture then fails after an hour of runner time. +""" +from __future__ import annotations + +import json +import subprocess +import sys + + +def select_device(devices, preferred): + """Return the first available device whose name starts with a preferred name. + + `devices` is the flat list simctl reports; `preferred` is a list of names + in order of preference. Returns the device dict, or None when nothing + matches. + """ + available = [device for device in devices if device.get("isAvailable")] + for want in preferred: + for device in available: + if device.get("name", "").startswith(want): + return device + return None + + +def list_devices(): + """Return every simulator device simctl reports, available or not.""" + raw = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "-j"], + capture_output=True, + text=True, + check=True, + ).stdout + data = json.loads(raw) + return [device for group in data.get("devices", {}).values() for device in group] + + +def main(argv): + if len(argv) != 2: + print( + "usage: pick_simulator.py ", + file=sys.stderr, + ) + return 2 + device = select_device(list_devices(), argv[1].split("|")) + if device is None: + return 1 + print(device["udid"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ios-fleet/test_pick_simulator.py b/scripts/ios-fleet/test_pick_simulator.py new file mode 100644 index 00000000..540434cd --- /dev/null +++ b/scripts/ios-fleet/test_pick_simulator.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Tests for the simulator picker the App Store screenshot capture depends on. + +No simulator and no network: the device list is a fixture that mirrors the +names Xcode reports, including the iPad storage variants that a plain equality +match would miss. +""" +from __future__ import annotations + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pick_simulator import select_device # noqa: E402 + +# The names Xcode 27.x reports. The iPads carry their storage variant in the +# name, which is exactly what an equality match would miss. +DEVICES = [ + {"name": "iPhone 18 Pro", "udid": "A0", "isAvailable": True}, + {"name": "iPhone 17 Pro Max", "udid": "A1", "isAvailable": True}, + {"name": "iPhone 16 Plus", "udid": "A2", "isAvailable": True}, + {"name": "iPad Pro 13-inch (M5) (16GB)", "udid": "B0", "isAvailable": True}, + {"name": "iPad Pro 13-inch (M5) (12GB)", "udid": "B1", "isAvailable": True}, + {"name": "iPad Pro 11-inch (M5) (16GB)", "udid": "B2", "isAvailable": True}, + {"name": "iPhone 15 Pro Max", "udid": "A3", "isAvailable": False}, +] + +IPHONE_PREFERRED = "iPhone 17 Pro Max|iPhone 16 Pro Max|iPhone 15 Pro Max" +IPAD_PREFERRED = ( + "iPad Pro 13-inch (M5)|iPad Pro 13-inch (M4)" + "|iPad Pro 12.9-inch (6th generation)" +) + + +class SelectDeviceTests(unittest.TestCase): + def test_picks_the_first_preferred_name(self): + device = select_device(DEVICES, IPHONE_PREFERRED.split("|")) + self.assertIsNotNone(device) + self.assertEqual(device["udid"], "A1") + + def test_matches_an_ipad_named_with_its_storage_variant(self): + device = select_device(DEVICES, IPAD_PREFERRED.split("|")) + self.assertIsNotNone(device) + self.assertTrue(device["name"].startswith("iPad Pro 13-inch (M5)")) + + def test_falls_through_to_the_next_preferred_name(self): + device = select_device(DEVICES, ["iPhone 16 Pro Max", "iPhone 16 Plus"]) + self.assertIsNotNone(device) + self.assertEqual(device["udid"], "A2") + + def test_skips_a_device_that_is_not_available(self): + device = select_device(DEVICES, ["iPhone 15 Pro Max"]) + self.assertIsNone(device) + + def test_returns_nothing_when_no_name_matches(self): + device = select_device(DEVICES, ["iPad Pro 12.9-inch (3rd generation)"]) + self.assertIsNone(device) + + def test_does_not_match_a_different_model_with_a_shared_prefix(self): + device = select_device(DEVICES, ["iPad Pro 13-inch (M4)"]) + self.assertIsNone(device) + + +if __name__ == "__main__": + unittest.main() From 73a92de6e8e1e5a499f834554a8c1657ad9b1827 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:53:47 -0500 Subject: [PATCH 06/50] CC-169: always regenerate the companion project before the capture The checked-in CodeCapsCompanion.xcodeproj is stale. It is missing three shared QuotaCore files the companion compiles against, so a build from it fails to compile. The capture only regenerated when project.yml was newer than the project file, and a checkout gives both the same mtime, so the comparison came out false and the script built the stale project. xcodegen takes a second, so the project is now always regenerated from project.yml. --- scripts/ios-fleet/capture-appstore-screenshots.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index e5390ad6..b0d397ce 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -249,11 +249,12 @@ capture_on() { main() { command -v xcodegen >/dev/null 2>&1 || fail "xcodegen is not installed" - if [ ! -f "$PROJECT_DIR/CodeCapsCompanion.xcodeproj/project.pbxproj" ] \ - || [ "$PROJECT_DIR/project.yml" -nt "$PROJECT_DIR/CodeCapsCompanion.xcodeproj/project.pbxproj" ]; then - log "regenerating the companion project" - ( cd "$PROJECT_DIR" && xcodegen generate ) - fi + # Always regenerate. The checked-in project is stale: it is missing shared + # QuotaCore files the companion compiles against, and a checkout gives + # project.yml and the project file the same mtime, so comparing them would + # skip the regeneration and build a project that does not compile. + log "regenerating the companion project" + ( cd "$PROJECT_DIR" && xcodegen generate ) local iphone_udid ipad_udid if ! iphone_udid="$(pick_simulator "$IPHONE_PREFERRED")"; then From 15370c5c498c6b20eb2891e72b28240a5b8af541 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:06:30 -0500 Subject: [PATCH 07/50] CC-169: refuse a mixed screenshot list before anything is reserved The upload read the pixel size of the first file and sent every other file to that file's screenshot set, so an iPhone and iPad capture in one call would have filed the iPad images into the iPhone set. Every file's size is read now and the call stops if they do not all belong to the same set, naming the set they do belong to. The check runs before the first reservation, so a wrong call costs nothing against the live version. --- scripts/ios-fleet/asc-api.mjs | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index a83b6307..59df632d 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -418,22 +418,37 @@ async function main() { "2048x2732": "APP_IPAD_PRO_3GEN_129", "2000x2664": "APP_IPAD_PRO_3GEN_129" }; - if (displayType === "auto") { - if (!existsSync(files[0])) { - console.error(`upload-screenshots: no such file: ${files[0]}`); + // Every file has to belong to the same screenshot set. A capture run + // produces iPhone and iPad files together and they belong in different + // sets, so a mixed list is refused here rather than sending the second + // family's files to the first family's set. + const fileSizes = new Map(); + for (const file of files) { + if (!existsSync(file)) { + console.error(`upload-screenshots: no such file: ${file}`); process.exit(2); } - const header = readFileSync(files[0]).subarray(0, 24); - const width = header.readUInt32BE(16); - const height = header.readUInt32BE(20); - const size = `${width}x${height}`; - displayType = sizeToDisplayType[size]; - if (!displayType) { + const header = readFileSync(file).subarray(0, 24); + const size = `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}`; + const type = sizeToDisplayType[size]; + if (!type) { console.error(`upload-screenshots: ${size} is not a size App Store Connect accepts`); process.exit(2); } - console.error(`upload-screenshots: ${size} -> ${displayType}`); + fileSizes.set(file, { size, type }); + } + if (displayType === "auto") { + displayType = fileSizes.get(files[0]).type; + } + for (const file of files) { + const { size, type } = fileSizes.get(file); + if (type !== displayType) { + console.error(`upload-screenshots: ${file} is ${size}, which belongs to ${type}, not ${displayType}`); + console.error(`upload-screenshots: run the ${type} files on their own`); + process.exit(2); + } } + console.error(`upload-screenshots: ${fileSizes.get(files[0]).size} -> ${displayType}`); const sets = await api("GET", `/v1/appStoreVersionLocalizations/${localizationId}/appScreenshotSets`); if (!sets.ok) { From 6a27530064249edaa20a40bf78cf56e6efffa513 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:20:06 -0500 Subject: [PATCH 08/50] CC-169: wait for the app to draw before capturing a screenshot The capture was writing a blank window. The companion takes about eighteen seconds to show its first frame on a cold simulator and about nine on a warm one, so the fixed four-second sleep was firing long before anything was on screen. The app was never broken: a ten-second sleep showed the full quota list. The script now polls. It takes a frame, measures how much of it differs from the background, and keeps the first frame that clears the bar. A window with nothing but its status bar measures about 0.3 percent, a half-drawn screen about 4, and a finished screen between 40 and 55, so the bar sits at ten percent, clear of both ends. The measurement lives in screenshot_ink.py behind nine tests. Three more defects went with it. The old check deleted the file it was checking inside a command substitution, so the delete printed the file name into the captured value and the run aborted. The output was renamed with a move onto itself, which did nothing. And the file was written as a PNG with an alpha channel, which App Store Connect rejects; the simulator writes JPEG, which cannot carry alpha, and it is converted back to an alpha-free PNG. The captured files are now named after the display set they belong to, so an iPhone file and an iPad file cannot be uploaded into the wrong set. The snapshot is re-seeded before every launch, so the time on a card is the time of its own shot. Apple's screenshot specification has no 2000 x 2664 device, so that size is gone from the script and from the upload step; the 12.9-inch Pro is 2048 x 2732. A compiled Python cache file was tracked by mistake and is now ignored. --- .../workflows/ios-appstore-screenshots.yml | 8 +- .github/workflows/ios-ship-contract.yml | 1 + .gitignore | 2 + .../pick_simulator.cpython-314.pyc | Bin 3066 -> 0 bytes scripts/ios-fleet/asc-api.mjs | 8 +- .../ios-fleet/capture-appstore-screenshots.sh | 189 ++++++++++++------ scripts/ios-fleet/screenshot_ink.py | 124 ++++++++++++ scripts/ios-fleet/test_screenshot_ink.py | 149 ++++++++++++++ 8 files changed, 414 insertions(+), 67 deletions(-) delete mode 100644 scripts/ios-fleet/__pycache__/pick_simulator.cpython-314.pyc create mode 100644 scripts/ios-fleet/screenshot_ink.py create mode 100644 scripts/ios-fleet/test_screenshot_ink.py diff --git a/.github/workflows/ios-appstore-screenshots.yml b/.github/workflows/ios-appstore-screenshots.yml index 14cb0611..05c48958 100644 --- a/.github/workflows/ios-appstore-screenshots.yml +++ b/.github/workflows/ios-appstore-screenshots.yml @@ -8,11 +8,17 @@ name: iOS App Store Screenshots # screen at the exact pixel size App Store Connect accepts, asserting the size # before the file is handed on. # +# The app needs seconds to draw its first frame, so the script polls the screen +# and keeps the first frame that holds more than the status bar. A fixed +# sleep captured a blank window, and a blank window is a picture of nothing. +# # The upload to App Store Connect is deliberately NOT done here. It is a # separate, explicit step run from a machine with the App Store Connect key: # # node scripts/ios-fleet/asc-api.mjs upload-screenshots \ -# auto iphone-fleet-status.png ipad-fleet-status.png +# auto app_iphone_67-fleet-status.png +# node scripts/ios-fleet/asc-api.mjs upload-screenshots \ +# auto app_ipad_pro_129-fleet-status.png # # so a re-run of this workflow can never quietly add duplicate screenshots to # a live version. diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index aa90cb2e..9b039c71 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -46,3 +46,4 @@ jobs: bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs python3 scripts/ios-fleet/test_pick_simulator.py + python3 scripts/ios-fleet/test_screenshot_ink.py diff --git a/.gitignore b/.gitignore index 14871ec4..94f53acf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ DerivedData/ xcuserdata/ _site/ ios/CodeCapsCompanion/build/ +__pycache__/ +*.pyc diff --git a/scripts/ios-fleet/__pycache__/pick_simulator.cpython-314.pyc b/scripts/ios-fleet/__pycache__/pick_simulator.cpython-314.pyc deleted file mode 100644 index cd48f845c5bdba5504e90c47f41f195b9edb5a32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3066 zcmaJ@O>7&-6`uVgDN>ZE)RAmOvBy@Th_FT3h-xQ}twgb_SXH7Bj78)in;tKR;@ZpI z^~|jPPy}k)qOya+3Q*Jm+C$w-lS5JW)Etvza*8VTNirxHiS{qdXPwzbm)^M1Nzjtwo>ASw-Ks7i%=grXB2leO>DpSqjM>EjAHlp zSW_BNBB}Y_2bTN8C!ZuMDndnOIRcA1#nY!JPGh$g$~B8|fyo@PY;w`2*n0CV%&mrR z6X7!4Ai}I$&MYR_W3)yYqgCvX1}&u1XF-!mJmE8nxydX~V29Ew$F2ivigo$g)8t@>&K`Re%72wgc)jA`Cl0YXV>lK#1PcouZ@p~hY(>zwvz7J#VfB!->O=C&r$4kj z`BH?Sr1yuTzUrwK${S1nC{ait=dr2y{|Wb)jJy!7;1?v}WYB7m#UZ_A#+ky#jCw9m z_9c9wl_KF}63v28Nn-P~!v zcVGZx9MIviYx@9x_(uysKZn@EuM-FUo`QTjMLa&-upFyF7KSXB4-wCkX#{1!WKdRk z-4%Q|s$zUtPN>je8x!oyAImeKtV^ju*rH+ggR zgYm!i{-yWhH|`wy@r~})?whmA6JO*e@8e2@Tzdx|n`Rtv} z!?!yR-`aJj^XPi#(T$#`f1TYjP!GPZp|0NhDb$|({y_$Hy{>S168&QL*g^dd3FxHN zR!f2eJT-X?-X@oMG|kepEmu{Y|B$hCl6?9(^dr@77`vJTxQoGHgXS0C%?i zkf+UD@RYJr%!_F02tzCM}?P{^9RlpF@;7FZi#Ih)slj<~i}Twi#;2y{Ug zL|~Y8YF^GK0%O61!WJYVg@G>5k#H>o+-ON1x|B}>xBZJAb4|*5pheR~74!xACGI+b zVY^V>nG9W!m?O4Nd0vN!WIoWVM3BIkWv=fr=}|62160Qh_K$*gX)op>XBdW_frnp) zZW(=(-gUijtq`QfHhP}g$aZaXJn?OtkxBe3jgr})*6wAHk@~hBcy;~wwd1SidoM4a z-oVfNF?BQb;htNsuHj>M@aSzkx{hC2&rILOudMo?`=6Arjeq7}9f!Yv%S?ZvPk+Cb zndXuipW-LRjwZ~9OS2pXGAdi&gm`9J-?pi8UVUL6?mK&Bsh)wkQ7b7ka-U%aLL$FI`D~BJsS$E?9ik4kWn}o^_peVK>10a`&Fw7 z^wDezB=O|cEztNP2et{Q%PcHBVbBAEdvJk(TB;K^I~OrUA{mHzqBn@;FgTPD9CnD8 zp<70q$@I#|&lXq5e*Vs8D!bAC+Gf|T8@=!MerITHX(%|kuFY)k?@s)7^uv)`%Abz? z`Q%3@KmNhm!INv*(dF@tWcJ;6zUs_v>TN3ntKGjUtm}Ke%Cv8RW)!3<6=|?i$+ri| zN~P+Wl}eDVRBFDwDOM^>Vq-EWtVec(vXf;r+)ap6>X06Sg*siZ1Um?yKSFFs&I-Ao zIstb>-=?EXhFZptPeON3QxxUCo>BDgb|a<#Ym~W*y6&R%gG9TMd$6|+0{dWhM#>1Lr?zl%)4i&2; } fail() { echo "::error::capture: $*" >&2; exit 1; } @@ -62,28 +83,37 @@ pick_simulator() { python3 "$SCRIPT_DIR/pick_simulator.py" "$1" } -# Print "x" for a PNG, or nothing if it cannot be read. -png_size() { +# Print "x" for an image, or nothing if it cannot be read. +image_size() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null \ | awk '/pixelWidth/ {w = $2} /pixelHeight/ {h = $2} END {if (w && h) print w "x" h}' } -# Print the App Store Connect display class for a pixel size, or nothing. -display_class_for_size() { +# Print the App Store Connect display class and the device family for a pixel +# size, separated by a pipe, or nothing if the size is not one Apple accepts. +# +# 1320x2868 -> APP_IPHONE_67|iphone +# 2064x2752 -> APP_IPAD_PRO_129|ipad +# +# App Store Connect has no APP_IPHONE_69 and no APP_IPAD_PRO_13: the +# 6.9-inch iPhone size lives in APP_IPHONE_67 and the 13-inch iPad size in +# APP_IPAD_PRO_129. +class_for_size() { case "$1" in - 1320x2868|1290x2796|1284x2778|1242x2688) echo "iphone" ;; - 2064x2752|2048x2732|2000x2664) echo "ipad" ;; + 1320x2868|1290x2796) echo "APP_IPHONE_67|iphone" ;; + 1284x2778|1242x2688) echo "APP_IPHONE_65|iphone" ;; + 2064x2752) echo "APP_IPAD_PRO_129|ipad" ;; + 2048x2732) echo "APP_IPAD_PRO_3GEN_129|ipad" ;; esac } -# Write a realistic quota snapshot into the simulator's app container. +# Write a realistic quota snapshot to the file named by the first argument. seed_snapshot() { - local container="$1" - python3 - "$container" <<'PY' + local target="$1" + python3 - "$target" <<'PY' import datetime, json, os, sys -container = sys.argv[1] -target = os.path.join(container, "Library", "Application Support", "CodeCaps", "quota-windows.json") +target = sys.argv[1] os.makedirs(os.path.dirname(target), exist_ok=True) now = datetime.datetime.now(datetime.timezone.utc) @@ -147,34 +177,57 @@ print(f"seeded {len(windows)} windows across {len(groups)} platforms -> {target} PY } -# Check a captured PNG against the size App Store Connect accepts for its -# display class, and flatten it if it carries an alpha channel, which Apple -# rejects. The accepted file path is printed on stdout; every log line goes -# to stderr so the caller can capture the path cleanly. -assert_screenshot() { +# Wait until the app has drawn its first frame, then leave that frame at the +# path given by the second argument. +# +# The companion takes seconds to render on a cold simulator, and a fixed sleep +# captured a blank window more than once. This polls instead: take a frame, +# measure how much of it is not background, and stop as soon as the screen +# holds more than the status bar. +wait_for_render() { + local udid="$1" frame="$2" + local deadline=$((SECONDS + RENDER_TIMEOUT_SEC)) + while [ "$SECONDS" -lt "$deadline" ]; do + xcrun simctl io "$udid" screenshot --type=jpeg "$frame" >/dev/null 2>&1 || true + if [ -s "$frame" ]; then + local status=0 + python3 "$SCRIPT_DIR/screenshot_ink.py" "$frame" \ + --min-percent "$RENDER_MIN_PERCENT" || status=$? + if [ "$status" -eq 0 ]; then + return 0 + fi + if [ "$status" -ne 1 ]; then + fail "could not measure the screen on $udid" + fi + fi + sleep "$RENDER_POLL_SEC" + done + fail "the app drew no screen within ${RENDER_TIMEOUT_SEC}s on $udid" +} + +# Check a captured file against the size App Store Connect accepts and +# against the size the other captures on this device have. The file to keep +# is left in CHECKED_FILE: the check must not delete anything, because a +# command substitution would swallow whatever the delete printed. +CHECKED_FILE="" +check_capture() { local file="$1" want="$2" - local size alpha flattened - size="$(png_size "$file")" + local size alpha + size="$(image_size "$file")" if [ -z "$size" ]; then fail "$(basename "$file") could not be read" fi if [ "$size" != "$want" ]; then fail "$(basename "$file") is $size, but the other captures are $want" fi - if [ -z "$(display_class_for_size "$size")" ]; then + if [ -z "$(class_for_size "$size")" ]; then fail "$(basename "$file") is $size, which App Store Connect does not accept for any display class" fi alpha="$(sips -g hasAlpha "$file" 2>/dev/null | awk '/hasAlpha/ {print $2}')" if [ "$alpha" = "yes" ]; then - # Flatten to JPEG, which cannot carry an alpha channel, and use that file. - flattened="${file%.png}.jpg" - sips -s format jpeg -s formatOptions 92 "$file" --out "$flattened" >/dev/null - rm -f "$file" - log "flattened $(basename "$flattened") (the PNG carried an alpha channel)" - echo "$flattened" - return + fail "$(basename "$file") still has an alpha channel, which App Store Connect rejects" fi - echo "$file" + CHECKED_FILE="$file" } # Build, install, seed, launch and capture on one simulator. @@ -182,7 +235,7 @@ capture_on() { local udid="$1" label="$2" kind="$3" local derived="$DERIVED_ROOT/$label" local app="$derived/Build/Products/Debug-iphonesimulator/CodeCapsCompanion.app" - local container bundle_id + local container group_container bundle_id log "booting $label ($udid)" xcrun simctl boot "$udid" 2>/dev/null || true @@ -202,48 +255,60 @@ capture_on() { xcrun simctl install "$udid" "$app" container="$(xcrun simctl get_app_container "$udid" "$bundle_id" data)" - seed_snapshot "$container" - - # Three screens per device: the quota list, the settings sheet, and the - # platform-order editor. The settings sheet and the editor are opened with - # the launch arguments the app itself understands. The app is launched - # before every capture, so no shot is ever the home screen. + # The companion reads the App Group container when the build is signed and + # its own sandbox when it is not, so seed whichever ones exist. + group_container="$(xcrun simctl get_app_container "$udid" "$bundle_id" groups 2>/dev/null | head -1)" + + # Three screens per device: the quota list, the platform-order editor, and + # the reset-alarm settings. The last two are tabs of the settings sheet, so + # each one needs the sheet opened as well as its own argument. The app is + # launched before every capture, so no shot is ever the home screen. local name - for name in fleet-status settings platform-order; do + for name in fleet-status platform-order alarms; do local args=() case "$name" in - settings) args=(-openSettings) ;; - platform-order) args=(-reorderPlatforms) ;; + platform-order) args=(-openSettings -reorderPlatforms) ;; + alarms) args=(-openSettings -openAlarms) ;; esac xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + # Re-seed before each launch so the "Updated" time on the cards is the + # time of the shot rather than the time the run started. + seed_snapshot "$container/Library/Application Support/CodeCaps/quota-windows.json" >/dev/null + if [ -n "$group_container" ]; then + seed_snapshot "$group_container/quota-windows.json" >/dev/null + fi xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" "${args[@]}" >/dev/null - sleep 4 - xcrun simctl io "$udid" screenshot "$OUT_DIR/$label-$name.png" >/dev/null 2>&1 || true - [ -s "$OUT_DIR/$label-$name.png" ] || fail "no screenshot for $label $name" + + # The simulator writes JPEG, which cannot carry an alpha channel, and + # App Store Connect rejects a screenshot that has one. It is converted + # back to PNG so the upload step can read the size out of the header. + local raw="$derived/$label-$name.jpg" + wait_for_render "$udid" "$raw" + sips -s format png "$raw" --out "$OUT_DIR/$label-$name.png" >/dev/null + log "captured $label $name" done xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true # The captured size decides the display class and the file name, so the # output says what the pixels actually are. - local size class_name - size="$(png_size "$OUT_DIR/$label-fleet-status.png")" - class_name="$(display_class_for_size "$size")" + local size class_name family + size="$(image_size "$OUT_DIR/$label-fleet-status.png")" + class_name="$(class_for_size "$size")" [ -n "$class_name" ] || fail "$label rendered at $size, which App Store Connect does not accept" + IFS='|' read -r display_type family <<<"$class_name" # The family we booted has to be the family the pixels are, or the two # captures would be uploaded into the wrong screenshot sets. - [ "$class_name" = "$kind" ] \ - || fail "$label rendered at $size, which is a $class_name capture, not a $kind one" - - local checked - for name in fleet-status settings platform-order; do - checked="$(assert_screenshot "$OUT_DIR/$label-$name.png" "$size")" - if [ "$checked" != "$OUT_DIR/$label-$name.png" ]; then - mv "$checked" "$OUT_DIR/$class_name-$name.jpg" - rm -f "$OUT_DIR/$label-$name.png" - else - mv "$checked" "$OUT_DIR/$class_name-$name.png" - fi - log "$class_name $name ($size)" + [ "$family" = "$kind" ] \ + || fail "$label rendered at $size, which is a $family capture, not a $kind one" + + # The file is named after the display set it belongs to, so the two + # families cannot be mixed up when the files are handed to the upload. + local stem + stem="$(echo "$display_type" | tr '[:upper:]' '[:lower:]')" + for name in fleet-status platform-order alarms; do + check_capture "$OUT_DIR/$label-$name.png" "$size" + mv "$CHECKED_FILE" "$OUT_DIR/$stem-$name.png" + log "$display_type $name ($size)" done } diff --git a/scripts/ios-fleet/screenshot_ink.py b/scripts/ios-fleet/screenshot_ink.py new file mode 100644 index 00000000..32ccd2cc --- /dev/null +++ b/scripts/ios-fleet/screenshot_ink.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Measure how much of a simulator screenshot is not background. + +The companion app needs several seconds to draw its first frame on a cold +simulator, and a fixed sleep captured a blank window more than once. The +capture script polls instead: it takes a frame and asks this module whether +the screen holds more than the status bar. + +The measurement is the share of pixels that differ from the frame's own +background colour. A blank app window is the background plus the status bar, +which measures about 0.2 percent; a drawn screen measures tens of percent. +The threshold sits far from both, so a slow frame is never mistaken for a +blank one and a blank one is never mistaken for a slow frame. +""" +from __future__ import annotations + +import argparse +import os +import struct +import subprocess +import sys +import tempfile +from collections import Counter + +# The share of non-background pixels a drawn screen has to reach before the +# capture script keeps the frame. A blank window measures about 0.2 percent +# and a drawn one about 48 percent. +DEFAULT_MIN_PERCENT = 1.0 + + +def ink_percent(channels): + """Return the share of pixels that differ from the frame's background. + + `channels` is a sequence of byte strings, one per colour channel, each + holding one byte per pixel. The background is the most common value of a + channel, and a pixel counts as ink when its byte differs from that + channel's background. The largest per-channel count wins: it over-counts + a pixel that differs in more than one channel and never under-counts one + that differs in any. + """ + if not channels: + return 0.0 + worst = 0 + for channel in channels: + if not channel: + continue + background = Counter(channel).most_common(1)[0][0] + differing = len(channel) - channel.count(background) + worst = max(worst, differing) + return worst * 100.0 / len(channels[0]) + + +def read_bmp_channels(path): + """Return the colour channels of a BMP file, one byte per pixel each. + + Both 24-bit and 32-bit BMPs are read, in either row order, because that is + what `sips` writes depending on whether the source had an alpha channel. + The channel order inside a pixel does not matter: the measurement only + compares each channel against its own background. + """ + with open(path, "rb") as handle: + data = handle.read() + if data[:2] != b"BM": + raise ValueError(f"{path} is not a BMP file") + offset = struct.unpack_from("= args.min_percent else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ios-fleet/test_screenshot_ink.py b/scripts/ios-fleet/test_screenshot_ink.py new file mode 100644 index 00000000..cea6a141 --- /dev/null +++ b/scripts/ios-fleet/test_screenshot_ink.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Tests for the screenshot ink measurement the App Store capture depends on. + +No simulator and no image library: the frames are synthetic BMPs built here, +in both pixel depths and both row orders, because that is the whole range +`sips` produces for the capture. +""" +from __future__ import annotations + +import os +import struct +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from screenshot_ink import ink_percent, read_bmp_channels # noqa: E402 + + +def build_bmp(width, height, rows, bits=24, top_down=True): + """Return a BMP whose pixels are the given rows of (b, g, r) tuples.""" + bytes_per_pixel = bits // 8 + stride = width * bytes_per_pixel + stride += (-stride) % 4 + order = rows if top_down else list(reversed(rows)) + pixels = bytearray() + for row in order: + for pixel in row: + blue, green, red = pixel + pixels += bytes((blue, green, red)) + if bytes_per_pixel == 4: + pixels += b"\xff" + pixels += b"\x00" * (stride - width * bytes_per_pixel) + offset = 54 + header = b"BM" + struct.pack(" Date: Mon, 19 Oct 2026 13:20:12 -0500 Subject: [PATCH 09/50] CC-169: label the source scope after the device and open the alarms tab on request The source-scope picker showed its stored raw value, which is always "This iPhone". On an iPad the reader therefore saw a control that said it was looking at an iPhone, which is the one thing the control is there to tell them. The stored value stays "This iPhone" so a saved preference is not lost, and a separate display name follows the interface idiom: iPhone, iPad, or Mac on the Mac build. The screenshot capture also needs to reach a tab of the settings sheet that has no launch argument of its own, so "-openAlarms" opens the Alarms tab the same way the existing arguments open the sheet and the platform-order editor. Both changes carry a test. --- Tests/CodeCapsTests/CompanionModelTests.swift | 52 +++++++++++++++++++ .../App/Models/CompanionQuotaModel.swift | 34 ++++++++++++ .../App/Views/CompanionContentView.swift | 9 +++- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/Tests/CodeCapsTests/CompanionModelTests.swift b/Tests/CodeCapsTests/CompanionModelTests.swift index 7fc84d3f..bd9239d6 100644 --- a/Tests/CodeCapsTests/CompanionModelTests.swift +++ b/Tests/CodeCapsTests/CompanionModelTests.swift @@ -454,4 +454,56 @@ final class CompanionModelTests: XCTestCase { "Reset alarm bell button must meet the 44x44 minimum touch target standard" ) } + + // MARK: - Source scope label names the device the reader is holding + + func testSourceScopeLabelNamesTheDeviceFamily() throws { + let source = try readCompanionModelSource() + XCTAssertTrue( + source.contains(#"case thisIPhone = "This iPhone""#), + "The scope's stored raw value must stay \"This iPhone\" so a saved preference is not lost" + ) + XCTAssertTrue( + source.contains(#"case .thisIPhone: return "This \(family)""#), + "The this-device scope must be labelled with the device the reader is holding" + ) + XCTAssertTrue( + source.contains("case .allSources: return rawValue"), + "The all-sources scope must keep its own label" + ) + XCTAssertTrue( + source.contains(#"UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone""#), + "The device family must follow the interface idiom, so an iPad never says This iPhone" + ) + XCTAssertTrue( + source.contains(#"return "Mac""#), + "The Mac build of the companion must name the Mac" + ) + } + + func testSourceScopePickerShowsTheDeviceAwareLabel() throws { + let view = try readCompanionContentViewSource() + XCTAssertTrue( + view.contains("Text(scope.displayTitle).tag(scope)"), + "The source-scope picker must show the device-aware label" + ) + XCTAssertFalse( + view.contains("Text(scope.rawValue).tag(scope)"), + "The source-scope picker must not show the stored raw value, which always says This iPhone" + ) + } + + // MARK: - A launch argument opens the alarms tab + + func testAlarmsLaunchArgumentOpensTheAlarmsTab() throws { + let view = try readCompanionContentViewSource() + XCTAssertTrue( + view.contains(#"ProcessInfo.processInfo.arguments.contains("-openAlarms") ? .alarms : .display"#), + "A launch argument must open the Alarms tab, so the screenshot capture can reach it without a tap" + ) + XCTAssertTrue( + view.contains(#"case alarms = "Alarms""#), + "The Alarms tab must still exist as a tab of the settings sheet" + ) + } } diff --git a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift index 31254159..9bdfc92c 100644 --- a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift +++ b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift @@ -3,12 +3,46 @@ import Security import SwiftUI import UserNotifications +#if os(iOS) +import UIKit +#endif + /// Whether the companion shows only this device's readings or every synced source. public enum CompanionSourceScope: String, CaseIterable, Identifiable, Sendable { case thisIPhone = "This iPhone" case allSources = "All Sources" public var id: String { rawValue } + + /// The label the source-scope picker shows. + /// + /// The stored raw value still says "This iPhone" because that is what it + /// always was and it is what a saved preference is keyed on. The app + /// ships for iPhone and iPad, and the label on screen has to name the + /// device the reader is actually holding. + public var displayTitle: String { + title(for: Self.currentDeviceFamily) + } + + /// The device word the picker label uses: "iPhone", "iPad" or "Mac". + public static var currentDeviceFamily: String { + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" + #else + return "Mac" + #endif + } + + /// The label for a given device family. + /// + /// It is separate from the idiom lookup so the wording can be checked + /// without a device. + public func title(for family: String) -> String { + switch self { + case .thisIPhone: return "This \(family)" + case .allSources: return rawValue + } + } } #if canImport(WidgetKit) import WidgetKit diff --git a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift index 77f44cf1..2b53fe7a 100644 --- a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift +++ b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift @@ -6,7 +6,7 @@ public struct CompanionContentView: View { @StateObject private var soundPlayer = AlarmSoundPlayer() @State private var showingSettings: Bool @State private var expandedIds: Set = [] - @State private var settingsTab: CompanionSettingsTab = .display + @State private var settingsTab: CompanionSettingsTab #if os(iOS) @State private var platformOrderEditMode: EditMode = .inactive #endif @@ -22,6 +22,11 @@ public struct CompanionContentView: View { public init(model: CompanionQuotaModel, showingSettings: Bool = ProcessInfo.processInfo.arguments.contains("-openSettings")) { self.model = model self._showingSettings = State(initialValue: showingSettings) + // A launch argument picks the tab the settings sheet opens on, so the + // screenshot capture can reach a tab without a tap. + self._settingsTab = State( + initialValue: ProcessInfo.processInfo.arguments.contains("-openAlarms") ? .alarms : .display + ) #if os(iOS) self._platformOrderEditMode = State( initialValue: ProcessInfo.processInfo.arguments.contains("-reorderPlatforms") ? .active : .inactive @@ -160,7 +165,7 @@ public struct CompanionContentView: View { private var headerCard: some View { Picker("Source scope", selection: $model.sourceScope) { ForEach(CompanionSourceScope.allCases) { scope in - Text(scope.rawValue).tag(scope) + Text(scope.displayTitle).tag(scope) } } .pickerStyle(.segmented) From d8e83015eb00cf79615aa0d843d5e574ba978656 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:16:37 +0000 Subject: [PATCH 10/50] autofix: pass empty launch args through Bash 3.2 set -u The first capture in the loop ("fleet-status") leaves args empty, and the runner runs the script with macOS system Bash 3.2. Under `set -u` that version treats "${args[@]}" on an empty array as an unbound variable, so the loop exited on its first iteration and no PNG was ever written. Use the ${ARR[@]:+"${ARR[@]}"} idiom the rest of scripts/ios-fleet already uses (ship-testflight.sh:1256), which expands to nothing when empty and to the quoted arguments otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/ios-fleet/capture-appstore-screenshots.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index c514aff5..4bec245c 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -277,7 +277,11 @@ capture_on() { if [ -n "$group_container" ]; then seed_snapshot "$group_container/quota-windows.json" >/dev/null fi - xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" "${args[@]}" >/dev/null + # ${args[@]:+...} rather than "${args[@]}": the first capture passes no + # arguments, and Bash 3.2 (the runner's system Bash) reads an empty array + # under `set -u` as an unbound variable and exits. + xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" \ + ${args[@]:+"${args[@]}"} >/dev/null # The simulator writes JPEG, which cannot carry an alpha channel, and # App Store Connect rejects a screenshot that has one. It is converted From 690bec34f088f5f0220a2e240de32e53da8f6487 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:20:01 -0500 Subject: [PATCH 11/50] CC-169: keep the capture script inside what Bash 3.2 can run The capture runs on a macOS runner whose system Bash is 3.2, and Bash 3.2 reads an empty array under "set -u" as an unbound variable. The first capture passes no launch arguments, so the unguarded form aborts the run on the very first screen, after the build has already spent its time. The autofix bot found it and changed the expansion to the guarded form. This adds a contract test so the same class of bug cannot come back. It checks that the script still parses, that every expansion of the launch-argument array is guarded, that the guarded form survives "set -u" with an empty array, that no Bash 4 builtin has crept in, and that the capture still waits for a drawn frame instead of sleeping. --- .github/workflows/ios-ship-contract.yml | 1 + scripts/ios-fleet/test-capture-script.sh | 51 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100755 scripts/ios-fleet/test-capture-script.sh diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index 9b039c71..3f08c45c 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -47,3 +47,4 @@ jobs: node --test scripts/ios-fleet/test-asc-credentials.mjs python3 scripts/ios-fleet/test_pick_simulator.py python3 scripts/ios-fleet/test_screenshot_ink.py + bash scripts/ios-fleet/test-capture-script.sh diff --git a/scripts/ios-fleet/test-capture-script.sh b/scripts/ios-fleet/test-capture-script.sh new file mode 100755 index 00000000..e38ce525 --- /dev/null +++ b/scripts/ios-fleet/test-capture-script.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Contract test for the App Store screenshot capture script. +# +# The capture runs on a macOS runner whose system Bash is 3.2, and Bash 3.2 +# reads an empty array under "set -u" as an unbound variable. The first +# capture passes no launch arguments at all, so the unguarded form +# "${args[@]}" aborts the run on the very first screen, after the build has +# already spent its time. The guarded form ${args[@]:+"${args[@]}"} is safe +# on every Bash the fleet uses, and this test keeps it that way. +# +# Usage: bash scripts/ios-fleet/test-capture-script.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CAPTURE="$SCRIPT_DIR/capture-appstore-screenshots.sh" + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "ok - $1"; } + +[[ -f "$CAPTURE" ]] || fail "the capture script is missing at $CAPTURE" + +# It still has to parse. +bash -n "$CAPTURE" || fail "the capture script does not parse" + +# Every expansion of the launch-argument array has to be guarded, so an empty +# array stays empty under "set -u" instead of becoming an unbound variable. +# shellcheck disable=SC2016 # the pattern is matched literally, not expanded +unguarded="$(grep -n '\${args\[@\]}' "$CAPTURE" | grep -v '\${args\[@\]:+' || true)" +[[ -z "$unguarded" ]] || fail "an unguarded array expansion is left in the capture script: +$unguarded" +pass "no unguarded array expansion" + +# The guarded form has to survive "set -u" with an empty array. +output="$(/bin/bash -c 'set -u; args=(); printf "%s" ${args[@]:+"${args[@]}"}' || true)" +[[ -z "$output" ]] || fail "the guarded expansion produced output for an empty array: $output" +pass "the guarded expansion is safe with an empty array" + +# Bash 4 builtins do not exist on the runner's system Bash. +newer="$(grep -nE '\b(readarray|mapfile)\b' "$CAPTURE" || true)" +[[ -z "$newer" ]] || fail "the capture script uses a Bash 4 builtin, which the runner's Bash 3.2 does not have: +$newer" +pass "no Bash 4 builtin" + +# The script must wait for a drawn frame rather than sleep a fixed number of +# seconds, which is what captured a blank window. +grep -q 'wait_for_render' "$CAPTURE" || fail "the capture script no longer waits for a drawn frame" +grep -q 'screenshot_ink.py' "$CAPTURE" || fail "the capture script no longer measures the frame before keeping it" +pass "the capture waits for a drawn frame" + +echo "capture script contract: 4 checks passed" From 597996e61243661cb5d2164b3286aa3c229ea42d Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:13:52 -0500 Subject: [PATCH 12/50] CC-169: answer the eight screenshot-tooling review findings Every finding raised on the App Store screenshot tooling is addressed here. Timeouts: every App Store Connect call now carries a 120s AbortSignal. Node's fetch has no default timeout, so a call that never answered would hang the upload with its reservation still open. Orphan reservations: a failed upload now DELETEs the reservation it opened, on every failure path. A screenshot set holds ten entries, so an open reservation is not cosmetic: it pushes the real screenshots out of the set. The delete is awaited and its outcome is reported, so the caller learns whether the entry actually went away. Error bodies: an App Store Connect failure no longer echoes the raw response. Only the JSON API error titles and details are used, with runs of 24 or more token-like characters redacted. A rejected key is named by variable -- ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 -- and never previewed. Shape guards: hand-written isPlainObject / requireObject / requireArray, because the scripts in this directory are dependency-free. Ink threshold: the quota list and the settings sheets are measured against separate minimums (10% and 6%), with a stability fallback that keeps a sparse frame once two polls agree. The background value is found from a stride-16 sample confirmed against the whole channel, which is 39x faster than counting every byte, with the exact mode as the fallback. Build once: both simulators install the same bundle, so the companion is compiled once instead of once per device. Workflow paths: the trigger lists the eight QuotaCore files the companion actually compiles, plus the two capture helpers. A broad Sources/QuotaCore filter would capture UI the shipped build does not contain, which is worse than a stale capture. New test: scripts/ios-fleet/test-asc-upload.mjs drives the real script against a local server and asserts that the reservation is dropped on every failure path and that no response body is echoed. It fails if the delete is fired without being awaited. --- .../workflows/ios-appstore-screenshots.yml | 17 ++ .github/workflows/ios-ship-contract.yml | 1 + scripts/ios-fleet/asc-api.mjs | 161 ++++++++++-- .../ios-fleet/capture-appstore-screenshots.sh | 124 ++++++--- scripts/ios-fleet/screenshot_ink.py | 34 ++- scripts/ios-fleet/test-asc-upload.mjs | 239 ++++++++++++++++++ scripts/ios-fleet/test-capture-script.sh | 28 +- scripts/ios-fleet/test_screenshot_ink.py | 20 +- 8 files changed, 569 insertions(+), 55 deletions(-) create mode 100644 scripts/ios-fleet/test-asc-upload.mjs diff --git a/.github/workflows/ios-appstore-screenshots.yml b/.github/workflows/ios-appstore-screenshots.yml index 05c48958..5a228388 100644 --- a/.github/workflows/ios-appstore-screenshots.yml +++ b/.github/workflows/ios-appstore-screenshots.yml @@ -29,7 +29,24 @@ on: branches: [main] paths: - "ios/CodeCapsCompanion/**" + # The exact shared files the companion compiles, and not the whole of + # Sources/QuotaCore: most QuotaCore edits are Mac-only, and a capture + # that shows more than the shipped build would be worse than a stale + # one. This list mirrors ios-ship.yml, so the screenshots and the + # TestFlight build change together. + - "Sources/QuotaCore/CompanionReadTokenStore.swift" + - "Sources/QuotaCore/FleetSourceDisagreement.swift" + - "Sources/QuotaCore/QuotaWindowVocabulary.swift" + - "Sources/QuotaCore/ResetAlarmCadence.swift" + - "Sources/QuotaCore/ResetAlarmMessage.swift" + - "Sources/QuotaCore/ResetAlarmTracker.swift" + - "Sources/QuotaCore/WidgetPlatformDisplay.swift" + - "Sources/QuotaCore/WidgetSnapshotFetcher.swift" + # The helpers the capture calls. A change to either of them changes + # what the capture keeps, so it has to bring a new capture with it. - "scripts/ios-fleet/capture-appstore-screenshots.sh" + - "scripts/ios-fleet/pick_simulator.py" + - "scripts/ios-fleet/screenshot_ink.py" - ".github/workflows/ios-appstore-screenshots.yml" concurrency: diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index 3f08c45c..edae646c 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -45,6 +45,7 @@ jobs: node --test scripts/ios-ship-workflow.test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs + node --test scripts/ios-fleet/test-asc-upload.mjs python3 scripts/ios-fleet/test_pick_simulator.py python3 scripts/ios-fleet/test_screenshot_ink.py bash scripts/ios-fleet/test-capture-script.sh diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 7dfd40ec..971f49a4 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -22,6 +22,15 @@ import { createSign } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; +// The App Store Connect host. A test points this at a local server so the +// failure paths can be exercised without touching the real service. +const API_BASE = process.env.ASC_API_BASE || "https://api.appstoreconnect.apple.com"; + +// How long a single App Store Connect call may take. Node's fetch has no +// default timeout, so a call that never answers would hang the script with a +// reservation still pending in App Store Connect. +const REQUEST_TIMEOUT_MS = 120_000; + // AFC-359: credentials come from the environment (CI sets ASC_KEY_ID / ASC_ISSUER_ID / // ASC_KEY_PATH), else Infisical Fleet-OPS /asc through fleet-secrets: ASC_KEY_ID, ASC_ISSUER_ID and // ASC_KEY_P8 (the .p8 contents). The key is used in memory only; nothing is written to disk or printed. @@ -361,15 +370,72 @@ async function main() { process.exit(1); } + // How long a single App Store Connect call may take. Node's fetch has no + // default timeout, so a call that never answers would hang the script with + // the reservation still pending in App Store Connect. + const REQUEST_TIMEOUT_MS = 120_000; + + // Describe a failed response without ever printing what it holds. + // + // The raw body of an App Store Connect error is not printed: it is + // whatever the service chose to say, and it may echo a header or a token. + // Only the titles and details of the JSON API errors are used, and a run of + // token-like characters in one of those is redacted. A rejected key is + // named, never previewed: a masked preview of a private key is still a + // piece of the key. + function describeError(res) { + if (res.status === 401 || res.status === 403) { + return "App Store Connect rejected the key: check ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 in Fleet-OPS prod /asc"; + } + const parts = []; + const errors = res.parsed && Array.isArray(res.parsed.errors) ? res.parsed.errors : []; + for (const problem of errors.slice(0, 3)) { + const title = typeof problem.title === "string" ? problem.title : "unknown error"; + const detail = typeof problem.detail === "string" ? problem.detail : ""; + parts.push(detail ? `${title}: ${detail}` : title); + } + if (parts.length === 0) { + parts.push("no JSON API error detail was returned"); + } + return parts + .map((part) => part.replace(/[A-Za-z0-9._~+/=-]{24,}/g, "[redacted]")) + .join("; "); + } + + // Guard the shape of a response before any of it is read. + // + // The scripts in this directory are dependency-free, so the guard is + // written out by hand rather than pulled in as a schema library. + function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function requireObject(value, what) { + if (!isPlainObject(value)) { + console.error(`upload-screenshots: invalid ${what} response`); + process.exit(2); + } + return value; + } + + function requireArray(value, what) { + if (!Array.isArray(value)) { + console.error(`upload-screenshots: invalid ${what} response`); + process.exit(2); + } + return value; + } + async function api(methodName, apiPath, jsonBody) { - const url = apiPath.startsWith("http") ? apiPath : `https://api.appstoreconnect.apple.com${apiPath}`; + const url = apiPath.startsWith("http") ? apiPath : `${API_BASE}${apiPath}`; const res = await fetch(url, { method: methodName, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: jsonBody ? jsonBody : undefined + body: jsonBody ? jsonBody : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); const text = await res.text(); let parsed; @@ -377,6 +443,34 @@ async function main() { return { status: res.status, ok: res.ok, parsed, text }; } + // Drop a reservation that will never be committed. + // + // A screenshot set holds ten entries, so a reservation that is left open + // after a failed upload sits in the set forever and pushes the real + // screenshots out of it. The delete is best effort: the script still + // reports the original failure, but it says whether the entry went away + // so a later run knows to look for it. + // + // Every caller has to await this before it exits the process: a pending + // fetch is cancelled by the exit, and the reservation would survive. + async function discardReservation(screenshotId) { + try { + const dropped = await api("DELETE", `/v1/appScreenshots/${screenshotId}`); + if (dropped.ok) { + console.error(`upload-screenshots: dropped the reservation ${screenshotId}`); + return true; + } + console.error(`upload-screenshots: the reservation ${screenshotId} is still open (HTTP ${dropped.status}): ${describeError(dropped)}`); + return false; + } catch (error) { + const reason = error && error.name === "TimeoutError" + ? "the delete timed out" + : `the delete failed (${error && error.message ? error.message : "unknown error"})`; + console.error(`upload-screenshots: the reservation ${screenshotId} is still open: ${reason}`); + return false; + } + } + // ------------------------------------------------------------------------- // App Store screenshot upload // ------------------------------------------------------------------------- @@ -452,11 +546,13 @@ async function main() { const sets = await api("GET", `/v1/appStoreVersionLocalizations/${localizationId}/appScreenshotSets`); if (!sets.ok) { - console.error(`upload-screenshots: could not list screenshot sets (HTTP ${sets.status})`); + console.error(`upload-screenshots: could not list screenshot sets (HTTP ${sets.status}): ${describeError(sets)}`); process.exit(2); } - let set = (sets.parsed.data || []).find( - (entry) => entry.attributes?.screenshotDisplayType === displayType + const setList = requireArray(sets.parsed?.data, "screenshot-set list"); + let set = setList.find( + (entry) => isPlainObject(entry) && typeof entry.id === "string" + && entry.attributes?.screenshotDisplayType === displayType ); if (!set) { const created = await api("POST", "/v1/appScreenshotSets", JSON.stringify({ @@ -471,11 +567,10 @@ async function main() { } })); if (!created.ok) { - console.error(`upload-screenshots: could not create the ${displayType} set (HTTP ${created.status})`); - console.error(created.text); + console.error(`upload-screenshots: could not create the ${displayType} set (HTTP ${created.status}): ${describeError(created)}`); process.exit(2); } - set = created.parsed.data; + set = requireObject(created.parsed?.data, "screenshot set"); console.error(`upload-screenshots: created the ${displayType} set ${set.id}`); } else { console.error(`upload-screenshots: reusing the ${displayType} set ${set.id}`); @@ -499,14 +594,20 @@ async function main() { } })); if (!reserved.ok) { - console.error(`upload-screenshots: could not reserve ${fileName} (HTTP ${reserved.status})`); - console.error(reserved.text); + console.error(`upload-screenshots: could not reserve ${fileName} (HTTP ${reserved.status}): ${describeError(reserved)}`); process.exit(2); } - const screenshot = reserved.parsed.data; - const operations = screenshot.attributes?.uploadOperations || []; + const screenshot = requireObject(reserved.parsed?.data, "screenshot reservation"); + if (typeof screenshot.id !== "string" || screenshot.id.length === 0) { + console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}`); + process.exit(2); + } + const operations = Array.isArray(screenshot.attributes?.uploadOperations) + ? screenshot.attributes.uploadOperations + : []; if (operations.length === 0) { console.error(`upload-screenshots: Apple returned no upload operation for ${fileName}`); + await discardReservation(screenshot.id); process.exit(2); } @@ -516,13 +617,27 @@ async function main() { const offset = operation.offset || 0; const length = operation.length || bytes.length; const chunk = bytes.subarray(offset, offset + length); - const put = await fetch(operation.url, { - method: operation.method || "PUT", - headers, - body: chunk - }); + let put; + try { + put = await fetch(operation.url, { + method: operation.method || "PUT", + headers, + body: chunk, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + } catch (error) { + // The reservation is still open in App Store Connect, and a set + // holds ten entries, so it is dropped before the script leaves. + await discardReservation(screenshot.id); + const reason = error && error.name === "TimeoutError" + ? "the upload timed out" + : `the upload failed (${error && error.message ? error.message : "unknown error"})`; + console.error(`upload-screenshots: upload of ${fileName} failed: ${reason}`); + process.exit(2); + } if (!put.ok) { console.error(`upload-screenshots: upload of ${fileName} failed (HTTP ${put.status})`); + await discardReservation(screenshot.id); process.exit(2); } } @@ -531,11 +646,12 @@ async function main() { data: { type: "appScreenshots", id: screenshot.id, attributes: { uploaded: true } } })); if (!committed.ok) { - console.error(`upload-screenshots: could not commit ${fileName} (HTTP ${committed.status})`); - console.error(committed.text); + console.error(`upload-screenshots: could not commit ${fileName} (HTTP ${committed.status}): ${describeError(committed)}`); + await discardReservation(screenshot.id); process.exit(2); } - const state = committed.parsed.data?.attributes?.assetDeliveryState; + const committedScreenshot = requireObject(committed.parsed?.data, "screenshot commit"); + const state = committedScreenshot.attributes?.assetDeliveryState; const stateName = state?.state || "UNKNOWN"; console.error(`upload-screenshots: ${fileName} -> ${screenshot.id} (${stateName})`); if (state?.errors?.length) { @@ -822,14 +938,15 @@ async function main() { process.exit(3); } - const url = path.startsWith("http") ? path : `https://api.appstoreconnect.apple.com${path}`; + const url = path.startsWith("http") ? path : `${API_BASE}${path}`; const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: body ? body : undefined + body: body ? body : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); const text = await res.text(); console.error(`HTTP ${res.status}`); diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index 4bec245c..2ab229f2 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -41,9 +41,24 @@ RENDER_POLL_SEC="${RENDER_POLL_SEC:-2}" # The share of non-background pixels a frame has to hold before it counts as # a drawn screen. A window that has drawn nothing but its status bar measures # about 0.3 percent, a frame that has drawn the navigation bar and no content -# about 4, and a finished screen between 40 and 55. Ten percent is clear of -# both ends: a half-drawn screen is never mistaken for a finished one. +# about 4, and the finished quota list between 40 and 55. Ten percent is +# clear of both ends: a half-drawn screen is never mistaken for a finished one. RENDER_MIN_PERCENT="${RENDER_MIN_PERCENT:-10.0}" +# The two settings screens are not the quota list. They are a navigation bar +# and a short form, so they cover far less of the screen than the quota cards +# do, and the same ten percent would be the wrong bar for them. On an iPad +# the same content covers a smaller share of a bigger screen, so the settings +# bar is lower than the quota bar. +RENDER_MIN_PERCENT_SETTINGS="${RENDER_MIN_PERCENT_SETTINGS:-6.0}" +# A screen that is sparse but finished is still a finished screen. A frame +# that has stopped changing is one, whatever it covers, so a frame above this +# floor is kept once two polls in a row measure the same value. The floor +# sits above the bare navigation bar so a half-drawn frame cannot reach it. +RENDER_STABLE_FLOOR="${RENDER_STABLE_FLOOR:-4.5}" +# How close two consecutive measurements have to be to count as unchanged, +# and how many polls have to agree before the frame is kept. +RENDER_STABLE_DELTA="${RENDER_STABLE_DELTA:-0.2}" +RENDER_STABLE_POLLS="${RENDER_STABLE_POLLS:-2}" mkdir -p "$OUT_DIR" @@ -178,31 +193,58 @@ PY } # Wait until the app has drawn its first frame, then leave that frame at the -# path given by the second argument. +# path given by the second argument. The third argument is the share of the +# frame the screen has to hold before it counts as drawn. # # The companion takes seconds to render on a cold simulator, and a fixed sleep # captured a blank window more than once. This polls instead: take a frame, # measure how much of it is not background, and stop as soon as the screen -# holds more than the status bar. +# holds enough of it. A sparse screen is also kept once two polls in a row +# measure the same value, because a screen that has stopped changing is a +# screen that has finished drawing. +# Print the share of the frame that is not background, as a bare number. +# +# The measurement is the only thing that tells a drawn screen from a blank +# one, so it is worth the seconds it costs, and it is why the background is +# found from a strided sample rather than from every byte of the frame. +measure_ink() { + python3 "$SCRIPT_DIR/screenshot_ink.py" "$1" 2>/dev/null +} + wait_for_render() { - local udid="$1" frame="$2" + local udid="$1" frame="$2" min_percent="$3" local deadline=$((SECONDS + RENDER_TIMEOUT_SEC)) + local previous="" stable=0 percent="" while [ "$SECONDS" -lt "$deadline" ]; do xcrun simctl io "$udid" screenshot --type=jpeg "$frame" >/dev/null 2>&1 || true if [ -s "$frame" ]; then - local status=0 - python3 "$SCRIPT_DIR/screenshot_ink.py" "$frame" \ - --min-percent "$RENDER_MIN_PERCENT" || status=$? - if [ "$status" -eq 0 ]; then - return 0 - fi - if [ "$status" -ne 1 ]; then - fail "could not measure the screen on $udid" + percent="$(measure_ink "$frame")" + if [ -n "$percent" ]; then + if awk -v p="$percent" -v m="$min_percent" 'BEGIN { exit !(p >= m) }'; then + log "screen holds ${percent}% non-background pixels, at or above ${min_percent}%" + return 0 + fi + # A sparse screen settles instead of filling the frame. Two polls + # that measure the same value mean the drawing stopped, and a frame + # above the floor holds more than a bare navigation bar. + if [ -n "$previous" ] \ + && awk -v p="$percent" -v q="$previous" -v d="$RENDER_STABLE_DELTA" \ + 'BEGIN { exit !((p - q < 0 ? q - p : p - q) <= d) }' \ + && awk -v p="$percent" -v f="$RENDER_STABLE_FLOOR" 'BEGIN { exit !(p >= f) }'; then + stable=$((stable + 1)) + if [ "$stable" -ge "$RENDER_STABLE_POLLS" ]; then + log "screen settled at ${percent}% non-background pixels" + return 0 + fi + else + stable=0 + fi + previous="$percent" fi fi sleep "$RENDER_POLL_SEC" done - fail "the app drew no screen within ${RENDER_TIMEOUT_SEC}s on $udid" + fail "the app drew no screen within ${RENDER_TIMEOUT_SEC}s on $udid (last reading ${percent:-none}%)" } # Check a captured file against the size App Store Connect accepts and @@ -230,29 +272,40 @@ check_capture() { CHECKED_FILE="$file" } -# Build, install, seed, launch and capture on one simulator. +# The app bundle the capture installs on both simulators. +# +# Both simulators run the same iOS runtime and the same Debug-iphonesimulator +# product, so one build serves both. Building per device compiled the +# companion and the shared QuotaCore files from scratch a second time, which +# added minutes to the job and reported any compile failure twice. +APP_PATH="$DERIVED_ROOT/app/Build/Products/Debug-iphonesimulator/CodeCapsCompanion.app" + +# Build the companion once, against the first simulator. +build_app() { + local udid="$1" + log "building the companion" + ( cd "$PROJECT_DIR" && xcodebuild -project CodeCapsCompanion.xcodeproj \ + -scheme "$SCHEME" \ + -destination "platform=iOS Simulator,id=$udid" \ + -derivedDataPath "$DERIVED_ROOT/app" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY="" \ + build >/dev/null ) + [ -d "$APP_PATH" ] || fail "no app built at $APP_PATH" +} + +# Install, seed, launch and capture on one simulator. capture_on() { local udid="$1" label="$2" kind="$3" local derived="$DERIVED_ROOT/$label" - local app="$derived/Build/Products/Debug-iphonesimulator/CodeCapsCompanion.app" local container group_container bundle_id log "booting $label ($udid)" xcrun simctl boot "$udid" 2>/dev/null || true xcrun simctl bootstatus "$udid" -b >/dev/null - log "building $label" - ( cd "$PROJECT_DIR" && xcodebuild -project CodeCapsCompanion.xcodeproj \ - -scheme "$SCHEME" \ - -destination "platform=iOS Simulator,id=$udid" \ - -derivedDataPath "$derived" \ - CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY="" \ - build >/dev/null ) - [ -d "$app" ] || fail "no app built at $app" - - bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Info.plist")" + bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist")" log "installing $bundle_id" - xcrun simctl install "$udid" "$app" + xcrun simctl install "$udid" "$APP_PATH" container="$(xcrun simctl get_app_container "$udid" "$bundle_id" data)" # The companion reads the App Group container when the build is signed and @@ -266,9 +319,18 @@ capture_on() { local name for name in fleet-status platform-order alarms; do local args=() + # The quota list fills the screen and the settings sheets do not, so each + # one is measured against a bar that fits the screen it shows. + local min="$RENDER_MIN_PERCENT" case "$name" in - platform-order) args=(-openSettings -reorderPlatforms) ;; - alarms) args=(-openSettings -openAlarms) ;; + platform-order) + args=(-openSettings -reorderPlatforms) + min="$RENDER_MIN_PERCENT_SETTINGS" + ;; + alarms) + args=(-openSettings -openAlarms) + min="$RENDER_MIN_PERCENT_SETTINGS" + ;; esac xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true # Re-seed before each launch so the "Updated" time on the cards is the @@ -287,7 +349,7 @@ capture_on() { # App Store Connect rejects a screenshot that has one. It is converted # back to PNG so the upload step can read the size out of the header. local raw="$derived/$label-$name.jpg" - wait_for_render "$udid" "$raw" + wait_for_render "$udid" "$raw" "$min" sips -s format png "$raw" --out "$OUT_DIR/$label-$name.png" >/dev/null log "captured $label $name" done @@ -336,6 +398,8 @@ main() { log "iPhone simulator $iphone_udid" log "iPad simulator $ipad_udid" + build_app "$iphone_udid" + capture_on "$iphone_udid" "iphone" "iphone" capture_on "$ipad_udid" "ipad" "ipad" diff --git a/scripts/ios-fleet/screenshot_ink.py b/scripts/ios-fleet/screenshot_ink.py index 32ccd2cc..953b89e1 100644 --- a/scripts/ios-fleet/screenshot_ink.py +++ b/scripts/ios-fleet/screenshot_ink.py @@ -27,6 +27,34 @@ # and a drawn one about 48 percent. DEFAULT_MIN_PERCENT = 1.0 +# The modal byte value is taken from a strided sample rather than from every +# byte of the channel. A full Counter over the 3.8 million bytes of one +# channel of a 1320x2868 frame took about 0.96 seconds, and the capture script +# measures every frame it throws away, so that cost was paid several times per +# screen. A sample of every sixteenth byte names the same background in +# about 0.02 seconds, and the count that follows it is still exact because it +# runs over the whole channel. +MODE_SAMPLE_STRIDE = 16 + +# A frame whose sampled background does not hold at least this share of the +# channel has no dominant background, so the sample is not trustworthy and the +# exact mode is computed instead. A simulator frame is a page on a flat +# background and never reaches this, so the slow path is the exception. +DOMINANT_BACKGROUND_MINIMUM = 0.10 + + +def background_value(channel): + """Return the most common byte value in `channel`. + + The value is found from a strided sample and then confirmed against the + whole channel, so the answer is exact for any frame that has a dominant + background. A frame without one falls back to counting every value. + """ + candidate = Counter(channel[::MODE_SAMPLE_STRIDE]).most_common(1)[0][0] + if channel.count(candidate) >= len(channel) * DOMINANT_BACKGROUND_MINIMUM: + return candidate + return Counter(channel).most_common(1)[0][0] + def ink_percent(channels): """Return the share of pixels that differ from the frame's background. @@ -44,7 +72,7 @@ def ink_percent(channels): for channel in channels: if not channel: continue - background = Counter(channel).most_common(1)[0][0] + background = background_value(channel) differing = len(channel) - channel.count(background) worst = max(worst, differing) return worst * 100.0 / len(channels[0]) @@ -117,6 +145,10 @@ def main(argv): f"screenshot_ink: {os.path.basename(args.image)} holds {percent:.2f}% non-background pixels", file=sys.stderr, ) + # The bare number goes to stdout so the capture script can read it and + # compare two polls. The human line stays on stderr, so it never becomes + # part of a captured value. + print(f"{percent:.4f}") return 0 if percent >= args.min_percent else 1 diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs new file mode 100644 index 00000000..ccd0d489 --- /dev/null +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -0,0 +1,239 @@ +// asc-api.mjs upload-screenshots cleans up after itself and keeps quiet. +// +// A screenshot set holds ten entries, so a reservation that is left open by a +// failed upload is not cosmetic: it sits in the set forever and pushes the real +// screenshots out of it. And the body of an App Store Connect error is +// whatever the service chose to say, so echoing it can put a header or a token +// into a shell history or a CI log. +// +// These tests run the real script against a local HTTP server with a synthetic +// key, so the failure paths are exercised without touching App Store Connect +// and without a real secret anywhere. No network, no fleet-secrets. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const script = join(here, "asc-api.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-upload-test-")); + +// A synthetic EC key: the script signs a JWT with it and the server here never +// verifies the signature. +const keyPath = join(root, "synthetic.p8"); +writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" })); + +// A stub PNG: the script only reads the IHDR width and height out of bytes +// 16..23 to decide which screenshot set the file belongs to, and never looks +// at the signature, so the rest of the file is not needed. +const pngPath = join(root, "shot.png"); +const ihdr = Buffer.alloc(24); +ihdr.writeUInt32BE(1320, 16); +ihdr.writeUInt32BE(2868, 20); +writeFileSync(pngPath, ihdr); + +// A string the server puts in a body that the script must never echo. +const SECRET_MARKER = "leaked-token-marker-do-not-echo"; + +const SCREENSHOT_ID = "shot-abc-123"; +const SET_ID = "set-xyz-789"; + +// Run asc-api.mjs upload-screenshots against a local server and collect what +// happened. `behaviour` picks which step, if any, fails. +function upload(behaviour) { + return new Promise((resolve) => { + const calls = []; + // The pre-signed upload URL Apple hands out is absolute, so the port has to + // be known before a reservation is written -- and the port is only known + // once the server is listening. + let apiBase = ""; + const server = createServer(async (req, res) => { + const body = await new Promise((resolve2) => { + let acc = ""; + req.on("data", (chunk) => { acc += chunk; }); + req.on("end", () => resolve2(acc)); + }); + calls.push({ method: req.method, url: req.url }); + + const json = (status, payload) => { + const text = JSON.stringify(payload); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(text); + }; + + // The set already exists, so the run goes straight to the reservation. + if (req.url.endsWith("/appScreenshotSets") && req.method === "GET") { + return json(200, { data: [{ + id: SET_ID, + type: "appScreenshotSets", + attributes: { screenshotDisplayType: "APP_IPHONE_67" } + }] }); + } + if (req.url === "/v1/appScreenshots" && req.method === "POST") { + return json(201, { + data: { + id: SCREENSHOT_ID, + type: "appScreenshots", + attributes: { uploadOperations: [{ + method: "PUT", + url: `${apiBase}/upload-target`, + length: 24, + requestHeaders: [{ name: "Content-Type", value: "image/png" }] + }] } + } + }); + } + if (req.url === "/upload-target") { + if (behaviour.putFails) { + res.writeHead(500, { "Content-Type": "text/plain" }); + return res.end(`upstream said: ${SECRET_MARKER}`); + } + res.writeHead(200); + return res.end(); + } + if (req.url === `/v1/appScreenshots/${SCREENSHOT_ID}` && req.method === "PATCH") { + if (behaviour.commitFails) { + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR", + title: "The asset is not in a committable state.", + detail: `internal reference ${SECRET_MARKER}` + }] }); + } + return json(200, { data: { + id: SCREENSHOT_ID, + type: "appScreenshots", + attributes: { assetDeliveryState: { state: "COMPLETE", errors: [] } } + } }); + } + if (req.url === `/v1/appScreenshots/${SCREENSHOT_ID}` && req.method === "DELETE") { + res.writeHead(204); + return res.end(); + } + res.writeHead(404, { "Content-Type": "text/plain" }); + return res.end(SECRET_MARKER); + }); + + server.listen(0, "127.0.0.1", () => { + apiBase = `http://127.0.0.1:${server.address().port}`; + const child = spawn("node", [script, "upload-screenshots", "loc-1", "auto", pngPath], { + env: { + ...process.env, + ASC_API_BASE: apiBase, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_PATH: keyPath + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => { + server.close(() => resolve({ code, stdout, stderr, calls })); + }); + }); + }); +} + +const deletesFor = (calls) => calls + .filter((c) => c.method === "DELETE" && c.url === `/v1/appScreenshots/${SCREENSHOT_ID}`) + .length; + +// The delete is only worth anything if the caller learns whether it landed, so +// the script has to wait for the response rather than fire the request and exit. +// A DELETE that is merely *sent* still shows up at the server, which makes the +// request count the wrong thing to assert on: the reported outcome is the part +// that distinguishes a cleaned-up reservation from a silent orphan. +const reportsDropped = (stderr) => stderr.includes(`dropped the reservation ${SCREENSHOT_ID}`); + +test("a successful upload commits the reservation and leaves nothing open", async () => { + const { code, stderr, calls } = await upload({ putFails: false, commitFails: false }); + assert.equal(code, 0, `expected a clean exit, got ${code}: ${stderr}`); + assert.ok( + calls.some((c) => c.method === "PATCH" && c.url === `/v1/appScreenshots/${SCREENSHOT_ID}`), + "the commit call was never made" + ); + assert.equal(deletesFor(calls), 0, "a committed reservation must not be deleted"); +}); + +test("a failed upload drops the reservation instead of leaving it in the set", async () => { + const { code, stderr, calls } = await upload({ putFails: true, commitFails: false }); + assert.equal(code, 2, "a failed upload has to exit non-zero"); + assert.equal( + deletesFor(calls), 1, + "the open reservation was never dropped, so it would sit in the ten-entry set forever" + ); + assert.ok( + reportsDropped(stderr), + `the script exited without reporting that the reservation went away:\n${stderr}` + ); +}); + +test("a failed commit drops the reservation", async () => { + const { code, stderr, calls } = await upload({ putFails: false, commitFails: true }); + assert.equal(code, 2, "a failed commit has to exit non-zero"); + assert.equal(deletesFor(calls), 1, "the uncommitted reservation was never dropped"); + assert.ok( + reportsDropped(stderr), + `the script exited without reporting that the reservation went away:\n${stderr}` + ); +}); + +test("no response body is echoed, on any failure", async () => { + for (const behaviour of [ + { putFails: true, commitFails: false }, + { putFails: false, commitFails: true } + ]) { + const { stdout, stderr } = await upload(behaviour); + const printed = stdout + stderr; + assert.ok( + !printed.includes(SECRET_MARKER), + `the raw response body was echoed to the caller:\n${printed}` + ); + } +}); + +test("a rejected key is named, never previewed", async () => { + // The server answers 401 to everything; the script must not echo the body, + // and it must not print any part of a key either. + const base = await new Promise((resolve) => { + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end(`unauthorized: ${SECRET_MARKER}`); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); + const apiBase = `http://127.0.0.1:${base.address().port}`; + const result = await new Promise((resolve) => { + const child = spawn("node", [script, "upload-screenshots", "loc-1", "auto", pngPath], { + env: { + ...process.env, + ASC_API_BASE: apiBase, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_PATH: keyPath + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); + base.close(); + assert.equal(result.code, 2, "a rejected key has to exit non-zero"); + assert.ok(!result.stderr.includes(SECRET_MARKER), "the 401 body was echoed"); + assert.ok( + /ASC_KEY_ID|ASC_ISSUER_ID|ASC_KEY_P8/.test(result.stderr), + `the failure should name the variables to check, got: ${result.stderr}` + ); +}); \ No newline at end of file diff --git a/scripts/ios-fleet/test-capture-script.sh b/scripts/ios-fleet/test-capture-script.sh index e38ce525..70d6513d 100755 --- a/scripts/ios-fleet/test-capture-script.sh +++ b/scripts/ios-fleet/test-capture-script.sh @@ -48,4 +48,30 @@ grep -q 'wait_for_render' "$CAPTURE" || fail "the capture script no longer waits grep -q 'screenshot_ink.py' "$CAPTURE" || fail "the capture script no longer measures the frame before keeping it" pass "the capture waits for a drawn frame" -echo "capture script contract: 4 checks passed" +# The quota list fills the screen and the settings sheets do not, so one +# threshold for all three screens either fails the two settings captures or +# lets a half-drawn frame through. The settings screens need their own bar. +grep -q 'RENDER_MIN_PERCENT_SETTINGS' "$CAPTURE" \ + || fail "the capture script has no separate minimum for the settings screens" +grep -q 'wait_for_render "$udid" "$raw" "$min"' "$CAPTURE" \ + || fail "the capture script does not give each screen its own minimum" +pass "each screen is measured against a minimum that fits it" + +# A screen that is sparse but finished never reaches the high bar, so the +# wait has to be able to keep a frame that has stopped changing. +grep -q 'RENDER_STABLE_FLOOR' "$CAPTURE" \ + || fail "the capture script cannot keep a sparse screen that has settled" +grep -q 'RENDER_STABLE_POLLS' "$CAPTURE" \ + || fail "the capture script does not require the frame to hold still" +pass "a sparse screen that has settled is kept" + +# Both simulators run the same runtime and the same product, so the app is +# built once and installed on both. A second build compiles the companion and +# the shared QuotaCore files from scratch again, for nothing. +builds="$(grep -c 'xcodebuild' "$CAPTURE" || true)" +[[ "$builds" -eq 1 ]] || fail "the capture script runs $builds xcodebuild calls; it has to build the app once: +$builds" +grep -q 'APP_PATH=' "$CAPTURE" || fail "the capture script has no shared app path" +pass "the app is built once and installed on both simulators" + +echo "capture script contract: 8 checks passed" diff --git a/scripts/ios-fleet/test_screenshot_ink.py b/scripts/ios-fleet/test_screenshot_ink.py index cea6a141..e7966747 100644 --- a/scripts/ios-fleet/test_screenshot_ink.py +++ b/scripts/ios-fleet/test_screenshot_ink.py @@ -12,10 +12,11 @@ import sys import tempfile import unittest +from collections import Counter sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from screenshot_ink import ink_percent, read_bmp_channels # noqa: E402 +from screenshot_ink import background_value, ink_percent, read_bmp_channels # noqa: E402 def build_bmp(width, height, rows, bits=24, top_down=True): @@ -76,6 +77,23 @@ def drawn_frame(width=200, height=200): return rows +class BackgroundValueTests(unittest.TestCase): + def test_finds_the_background_of_a_frame_with_a_dominant_colour(self): + channel = b"\xf8" * 4000 + b"\x11" * 40 + self.assertEqual(background_value(channel), 0xF8) + + def test_a_stride_that_lands_only_on_ink_does_not_hide_the_background(self): + # Every sixteenth byte is ink and the rest is the background, so a + # strided sample names the wrong value. The sample has to be + # confirmed against the whole channel before it is believed. + channel = b"\x00" * 16 + b"\xff" * 240 + self.assertEqual(background_value(channel), 0xFF) + + def test_a_frame_without_a_dominant_background_still_measures(self): + channel = bytes(range(256)) * 4 + self.assertEqual(background_value(channel), Counter(channel).most_common(1)[0][0]) + + class InkPercentTests(unittest.TestCase): def test_a_uniform_frame_measures_nothing(self): channels = [b"\xff" * 100, b"\xff" * 100, b"\xff" * 100] From fbe9a61aa5eb97f07d1e26b2c28f7dd4abaf061e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:31:42 +0000 Subject: [PATCH 13/50] autofix: create the per-device capture directory and correct the check count The single-pass build writes to "$DERIVED_ROOT/app", so the per-device capture directory "$DERIVED_ROOT/$label" is no longer created as a side effect of building the app. capture_on() still writes its raw frame to "$derived/$label-$name.jpg", so `simctl io screenshot` had nowhere to write, wait_for_render saw an empty file on every poll, and the run died at the 180s timeout. Create it explicitly. Also correct the contract-test summary: it claimed 8 checks passed and there are 7. Co-Authored-By: Claude Code --- scripts/ios-fleet/capture-appstore-screenshots.sh | 5 +++++ scripts/ios-fleet/test-capture-script.sh | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index 2ab229f2..7e684f19 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -299,6 +299,11 @@ capture_on() { local derived="$DERIVED_ROOT/$label" local container group_container bundle_id + # The single build writes to "$DERIVED_ROOT/app", so the per-device capture + # directory is no longer created as a side effect of building. Without this + # the raw frame has nowhere to land and every poll reads an empty file. + mkdir -p "$derived" + log "booting $label ($udid)" xcrun simctl boot "$udid" 2>/dev/null || true xcrun simctl bootstatus "$udid" -b >/dev/null diff --git a/scripts/ios-fleet/test-capture-script.sh b/scripts/ios-fleet/test-capture-script.sh index 70d6513d..fdb2dfc0 100755 --- a/scripts/ios-fleet/test-capture-script.sh +++ b/scripts/ios-fleet/test-capture-script.sh @@ -74,4 +74,4 @@ $builds" grep -q 'APP_PATH=' "$CAPTURE" || fail "the capture script has no shared app path" pass "the app is built once and installed on both simulators" -echo "capture script contract: 8 checks passed" +echo "capture script contract: 7 checks passed" From 0dcb81e0d8e09efffd5b281f01464f0b53981227 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:39:06 +0000 Subject: [PATCH 14/50] autofix: reject a truncated PNG before reading its header The width and height sit at offsets 16 and 20 of the PNG header, but the only precheck on an upload argument was existsSync, so a file shorter than 24 bytes reached readUInt32BE and raised an uncaught RangeError. That surfaced as a stack trace and left the run exiting on the unhandled rejection rather than on the named exit 2 that every other bad input in this loop already gets. Reading the bytes once and rejecting anything too small to carry a signature and an IHDR header puts the failure back on the existing path. The bytes are reused by the upload loop below, which read the file a second time for the body. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 971f49a4..78a0fd6b 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -522,7 +522,16 @@ async function main() { console.error(`upload-screenshots: no such file: ${file}`); process.exit(2); } - const header = readFileSync(file).subarray(0, 24); + // The width and height sit at offsets 16 and 20, so anything shorter + // than a signature plus an IHDR header cannot be measured. Reading + // them anyway raises a RangeError with a stack trace instead of the + // named failure every other bad input gets here. + const bytes = readFileSync(file); + if (bytes.length < 24) { + console.error(`upload-screenshots: ${file} is ${bytes.length} bytes, too small to be a PNG`); + process.exit(2); + } + const header = bytes.subarray(0, 24); const size = `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}`; const type = sizeToDisplayType[size]; if (!type) { From ac5ced041c9f058097f2ca2f08f05662e97d7744 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:00:06 +0000 Subject: [PATCH 15/50] autofix: fix the real defects in the screenshot tooling review Answer the unresolved review findings that are genuine defects: - measure_ink swallowed screenshot_ink.py's exit status. The helper exits 1 below its own default minimum, which is exactly the blank-frame case the poll loop exists to survive, and under `set -e` the failing command substitution aborted the whole capture with a bare exit 1 and no ::error:: annotation. - ASC_API_BASE could redirect every request carrying the ASC-signed JWT to an arbitrary host. Only a loopback override is honoured now, which still covers the tests. - api() let a TimeoutError escape instead of returning a non-ok response, so the commit path skipped discardReservation() and left a reservation open in a ten-entry set. - The post-POST guards exited without dropping the reservation they had just created; they now discard it when an id is available and name the set when one is not. - describeError() read .title/.detail off unvalidated elements of the service's errors array, which throws on a null element and replaces the safe message with a stack trace. - Removed the shadowed module-level REQUEST_TIMEOUT_MS. The remaining threads were already addressed by 597996e or the following autofix rounds, and were left alone: the launch-before-capture, log-to-stderr and measured-pixel-size findings are in the current script, the workflow concurrency finding is out of bounds here, and the stable-frame floor and the mixed-upload refusal are deliberate documented behaviour. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 81 ++++++++++++++----- .../ios-fleet/capture-appstore-screenshots.sh | 7 +- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 78a0fd6b..361bf458 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -24,12 +24,19 @@ import { join } from "node:path"; // The App Store Connect host. A test points this at a local server so the // failure paths can be exercised without touching the real service. -const API_BASE = process.env.ASC_API_BASE || "https://api.appstoreconnect.apple.com"; - -// How long a single App Store Connect call may take. Node's fetch has no -// default timeout, so a call that never answers would hang the script with a -// reservation still pending in App Store Connect. -const REQUEST_TIMEOUT_MS = 120_000; +// +// Only a loopback override is honoured: every call carries an Authorization +// header holding a JWT signed with the real ASC private key, so a stray +// environment variable must not be able to redirect that token to another +// host. The production host is used for anything that is not loopback. +const API_BASE = (() => { + const override = process.env.ASC_API_BASE; + if (!override) return "https://api.appstoreconnect.apple.com"; + if (!/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/.test(override)) { + throw new Error("ASC_API_BASE must be a loopback address"); + } + return override; +})(); // AFC-359: credentials come from the environment (CI sets ASC_KEY_ID / ASC_ISSUER_ID / // ASC_KEY_PATH), else Infisical Fleet-OPS /asc through fleet-secrets: ASC_KEY_ID, ASC_ISSUER_ID and @@ -390,8 +397,12 @@ async function main() { const parts = []; const errors = res.parsed && Array.isArray(res.parsed.errors) ? res.parsed.errors : []; for (const problem of errors.slice(0, 3)) { - const title = typeof problem.title === "string" ? problem.title : "unknown error"; - const detail = typeof problem.detail === "string" ? problem.detail : ""; + // The errors array comes from the service, so an element may be null or + // a bare string. Reading .title off one of those throws, which would + // replace the safe message this function exists to produce with a stack + // trace, so the shape is checked before anything is read. + const title = isPlainObject(problem) && typeof problem.title === "string" ? problem.title : "unknown error"; + const detail = isPlainObject(problem) && typeof problem.detail === "string" ? problem.detail : ""; parts.push(detail ? `${title}: ${detail}` : title); } if (parts.length === 0) { @@ -426,17 +437,39 @@ async function main() { return value; } + // Every call is aborted after REQUEST_TIMEOUT_MS, so a call that never + // answers rejects with a TimeoutError rather than hanging. That rejection + // is turned into an ordinary non-ok response here rather than at each call + // site: the commit path in particular has to reach its discardReservation() + // on a timeout, and an escaping exception would skip it and leave the + // reservation open in a ten-entry set. async function api(methodName, apiPath, jsonBody) { const url = apiPath.startsWith("http") ? apiPath : `${API_BASE}${apiPath}`; - const res = await fetch(url, { - method: methodName, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json" - }, - body: jsonBody ? jsonBody : undefined, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) - }); + let res; + try { + res = await fetch(url, { + method: methodName, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: jsonBody ? jsonBody : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + } catch (error) { + const timedOut = error && error.name === "TimeoutError"; + return { + status: 0, + ok: false, + text: "", + parsed: { + errors: [{ + title: timedOut ? "the request timed out" : "the request failed", + detail: error && error.message ? error.message : "unknown error" + }] + } + }; + } const text = await res.text(); let parsed; try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; } @@ -606,9 +639,19 @@ async function main() { console.error(`upload-screenshots: could not reserve ${fileName} (HTTP ${reserved.status}): ${describeError(reserved)}`); process.exit(2); } - const screenshot = requireObject(reserved.parsed?.data, "screenshot reservation"); + // The reservation now exists in a set that holds ten entries, so it has + // to be dropped on every path that stops here. When the response shape + // gives back no usable id there is nothing to delete by, and the message + // says so rather than letting the run end quietly with a slot consumed. + const reservation = reserved.parsed?.data; + if (!isPlainObject(reservation)) { + console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}; a reservation may be open in set ${set.id}`); + process.exit(2); + } + const screenshot = reservation; if (typeof screenshot.id !== "string" || screenshot.id.length === 0) { - console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}`); + console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}; a reservation may be open in set ${set.id}`); + if (screenshot.id) await discardReservation(screenshot.id); process.exit(2); } const operations = Array.isArray(screenshot.attributes?.uploadOperations) diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index 7e684f19..ffc2f743 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -208,7 +208,12 @@ PY # one, so it is worth the seconds it costs, and it is why the background is # found from a strided sample rather than from every byte of the frame. measure_ink() { - python3 "$SCRIPT_DIR/screenshot_ink.py" "$1" 2>/dev/null + # screenshot_ink.py exits 1 when the frame holds less than its own default + # minimum, which is exactly the blank-frame case the poll loop below has to + # survive. Under `set -e` an assignment whose command substitution fails + # aborts the whole capture, so the status is swallowed here; an empty + # reading is already handled by the caller's `[ -n "$percent" ]` test. + python3 "$SCRIPT_DIR/screenshot_ink.py" "$1" 2>/dev/null || true } wait_for_render() { From a64389d69d1423ee8dfa9bf4f0a8cd06c3b0505b Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:19:19 -0500 Subject: [PATCH 16/50] CC-169: close the second round of screenshot-tooling findings Nine findings from the last review pass, all addressed here. Transport failures are no longer server verdicts. A request that never reached App Store Connect comes back from api() marked networkError, and it is reported as one: latest-build-seq and ensure-tf-ready say the request never arrived instead of saying the app was not found, and the newest-build probe keeps "unknown" rather than claiming the app has no builds at all. The host override accepts the production host explicitly, so a job that pins the host it would otherwise use is not a redirect anywhere new, and a trailing slash is stripped so the prefix cannot double the separator. The check moved out of module scope into main(), which has a catch handler, so a bad value is a named failure instead of an uncaught stack trace. Failures no longer lose their own message. Every failure path sets process.exitCode and returns, so Node drains stdout and stderr before the process ends; a pending write to a pipe is not flushed by process.exit(). The two guards that cannot return from main() throw a named failure that the handler prints without a stack trace. The capture gate no longer has a way around its own thresholds. The stable-frame path was a second way to accept a frame, gated on a global floor below the per-screen minimums, so a navigation bar and nothing else could be accepted for the quota list. It is now a second condition on the same thresholds rather than an alternative to them, and RENDER_STABLE_FLOOR is gone. To make that check possible, screenshot_ink.py reports two numbers: the share of the whole frame that is not background, and the share of the region below the header, which is where a screen's content lives. A frame that has drawn only its navigation bar has ink in the header and none below it, so the content share is zero and the gate rejects it however much of the frame it covers. That is the case a whole-frame threshold could not see. A failed measurement is no longer swallowed. measure_ink keeps the exit status: 1 is the blank-frame case the poll loop has to survive, and 2 is a measurement that failed, which stops the run with the real error instead of spending the whole render budget on every screen. New test: scripts/ios-fleet/test-asc-client.mjs drives the real client against a local server and a closed port. It asserts the production host is accepted, a host that is neither is refused by name, a trailing slash does not double the path separator, a transport failure is not reported as app not found, and a payload larger than a pipe buffer is not truncated. Every one of those was checked by reverting the fix and watching the test fail. test-capture-script.sh now sources the capture and calls its gate with real numbers: a navigation bar with no content is rejected for every screen, a half-drawn screen is rejected, and the finished quota list and settings sheet both pass. --- .github/workflows/ios-ship-contract.yml | 1 + scripts/ios-fleet/asc-api.mjs | 212 +++++++++++++----- .../ios-fleet/capture-appstore-screenshots.sh | 124 ++++++---- scripts/ios-fleet/screenshot_ink.py | 78 ++++++- scripts/ios-fleet/test-asc-client.mjs | 153 +++++++++++++ scripts/ios-fleet/test-capture-script.sh | 74 +++++- scripts/ios-fleet/test_screenshot_ink.py | 74 +++++- 7 files changed, 601 insertions(+), 115 deletions(-) create mode 100644 scripts/ios-fleet/test-asc-client.mjs diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index edae646c..3e8f3bf7 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -45,6 +45,7 @@ jobs: node --test scripts/ios-ship-workflow.test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs + node --test scripts/ios-fleet/test-asc-client.mjs node --test scripts/ios-fleet/test-asc-upload.mjs python3 scripts/ios-fleet/test_pick_simulator.py python3 scripts/ios-fleet/test_screenshot_ink.py diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 361bf458..67d46ab4 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -22,21 +22,43 @@ import { createSign } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; -// The App Store Connect host. A test points this at a local server so the -// failure paths can be exercised without touching the real service. +// A failure that already knows what to say. // -// Only a loopback override is honoured: every call carries an Authorization -// header holding a JWT signed with the real ASC private key, so a stray -// environment variable must not be able to redirect that token to another -// host. The production host is used for anything that is not loopback. -const API_BASE = (() => { +// A guard deep in the call stack cannot return from main(), so it throws this +// instead. The handler at the bottom of the file prints the message on its +// own, without a stack trace, and keeps the exit code the failure set. +class NamedFailure extends Error {} + +// The App Store Connect host. +const PRODUCTION_API_BASE = "https://api.appstoreconnect.apple.com"; + +// Resolve the host every call is made against. +// +// A test points this at a local server so the failure paths can be exercised +// without touching the real service. Only a loopback override is honoured: +// every call carries an Authorization header holding a JWT signed with the +// real ASC private key, so a stray environment variable must not be able to +// redirect that token to another host. +// +// The production host is accepted explicitly, because a job that pins the host +// it would otherwise use is not a redirect anywhere new. The value is used as +// a prefix for paths that already begin with "/", so a trailing slash is +// stripped to keep the separator from doubling. +// +// The check runs inside main(), which has a catch handler, so a bad value is +// reported as a named failure instead of an uncaught exception at module +// scope. +function resolveApiBase() { const override = process.env.ASC_API_BASE; - if (!override) return "https://api.appstoreconnect.apple.com"; + if (!override) return PRODUCTION_API_BASE; + const trimmed = override.replace(/\/+$/, ""); + if (trimmed === PRODUCTION_API_BASE) return PRODUCTION_API_BASE; if (!/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/.test(override)) { - throw new Error("ASC_API_BASE must be a loopback address"); + console.error("asc-api: ASC_API_BASE must be the production host or a loopback address"); + return null; } - return override; -})(); + return trimmed; +} // AFC-359: credentials come from the environment (CI sets ASC_KEY_ID / ASC_ISSUER_ID / // ASC_KEY_PATH), else Infisical Fleet-OPS /asc through fleet-secrets: ASC_KEY_ID, ASC_ISSUER_ID and @@ -75,14 +97,16 @@ function resolveAscCredentials() { } if (!keyId || !issuerId) { console.error("Missing ASC_KEY_ID / ASC_ISSUER_ID: set them in the environment or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc"); - process.exit(1); + process.exitCode = 1; + return null; } const privateKeyPem = keyId === ascKeyId ? fleetSecretsGet("ASC_KEY_P8", "/asc") : fleetSecretsGet(`AUTHKEY_${keyId}`, `/authkey-${keyId.toLowerCase()}-p8`); if (!privateKeyPem) { console.error(`Missing the .p8 for key ${keyId}: Infisical /asc ASC_KEY_P8 (the /asc key) or /authkey--p8 AUTHKEY_`); - process.exit(1); + process.exitCode = 1; + return null; } return { keyId, issuerId, privateKeyPem: privateKeyPem + "\n" }; } @@ -365,7 +389,17 @@ async function setWhatToTest({ api, buildId, appId, marketing }) { } async function main() { - const { keyId, issuerId, privateKeyPem } = resolveAscCredentials(); + const apiBase = resolveApiBase(); + if (!apiBase) { + process.exitCode = 2; + return; + } + const credentials = resolveAscCredentials(); + if (!credentials) { + // The helper already named what is missing and set the exit code. + return; + } + const { keyId, issuerId, privateKeyPem } = credentials; const token = signJwt({ keyId, issuerId, privateKeyPem }); const [method, path, body, arg4] = process.argv.slice(2); @@ -374,7 +408,8 @@ async function main() { console.error(" node asc-api.mjs ensure-tf-ready [marketingVersion]"); console.error(" node asc-api.mjs latest-build-seq "); console.error(" node asc-api.mjs upload-screenshots "); - process.exit(1); + process.exitCode = 1; + return; } // How long a single App Store Connect call may take. Node's fetch has no @@ -391,6 +426,9 @@ async function main() { // named, never previewed: a masked preview of a private key is still a // piece of the key. function describeError(res) { + if (res.networkError) { + return "the request never reached App Store Connect"; + } if (res.status === 401 || res.status === 403) { return "App Store Connect rejected the key: check ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 in Fleet-OPS prod /asc"; } @@ -417,22 +455,26 @@ async function main() { // // The scripts in this directory are dependency-free, so the guard is // written out by hand rather than pulled in as a schema library. + // + // A guard cannot return from main() on its own, so it throws a named + // failure instead: the caller unwinds to main()'s handler, which prints the + // message and keeps the exit code the guard set. function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function requireObject(value, what) { if (!isPlainObject(value)) { - console.error(`upload-screenshots: invalid ${what} response`); - process.exit(2); + process.exitCode = 2; + throw new NamedFailure(`upload-screenshots: invalid ${what} response`); } return value; } function requireArray(value, what) { if (!Array.isArray(value)) { - console.error(`upload-screenshots: invalid ${what} response`); - process.exit(2); + process.exitCode = 2; + throw new NamedFailure(`upload-screenshots: invalid ${what} response`); } return value; } @@ -444,7 +486,7 @@ async function main() { // on a timeout, and an escaping exception would skip it and leave the // reservation open in a ten-entry set. async function api(methodName, apiPath, jsonBody) { - const url = apiPath.startsWith("http") ? apiPath : `${API_BASE}${apiPath}`; + const url = apiPath.startsWith("http") ? apiPath : `${apiBase}${apiPath}`; let res; try { res = await fetch(url, { @@ -461,6 +503,10 @@ async function main() { return { status: 0, ok: false, + // The request never reached App Store Connect. A caller that only + // looks at the status would read this as a server verdict, so the + // transport failure is marked and reported as one. + networkError: true, text: "", parsed: { errors: [{ @@ -528,7 +574,8 @@ async function main() { const files = process.argv.slice(5); if (!localizationId || !displayType || files.length === 0) { console.error("Usage: node asc-api.mjs upload-screenshots "); - process.exit(2); + process.exitCode = 2; + return; } // "auto" reads the pixel size out of the file and picks the screenshot set @@ -553,7 +600,8 @@ async function main() { for (const file of files) { if (!existsSync(file)) { console.error(`upload-screenshots: no such file: ${file}`); - process.exit(2); + process.exitCode = 2; + return; } // The width and height sit at offsets 16 and 20, so anything shorter // than a signature plus an IHDR header cannot be measured. Reading @@ -562,14 +610,16 @@ async function main() { const bytes = readFileSync(file); if (bytes.length < 24) { console.error(`upload-screenshots: ${file} is ${bytes.length} bytes, too small to be a PNG`); - process.exit(2); + process.exitCode = 2; + return; } const header = bytes.subarray(0, 24); const size = `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}`; const type = sizeToDisplayType[size]; if (!type) { console.error(`upload-screenshots: ${size} is not a size App Store Connect accepts`); - process.exit(2); + process.exitCode = 2; + return; } fileSizes.set(file, { size, type }); } @@ -581,7 +631,8 @@ async function main() { if (type !== displayType) { console.error(`upload-screenshots: ${file} is ${size}, which belongs to ${type}, not ${displayType}`); console.error(`upload-screenshots: run the ${type} files on their own`); - process.exit(2); + process.exitCode = 2; + return; } } console.error(`upload-screenshots: ${fileSizes.get(files[0]).size} -> ${displayType}`); @@ -589,7 +640,8 @@ async function main() { const sets = await api("GET", `/v1/appStoreVersionLocalizations/${localizationId}/appScreenshotSets`); if (!sets.ok) { console.error(`upload-screenshots: could not list screenshot sets (HTTP ${sets.status}): ${describeError(sets)}`); - process.exit(2); + process.exitCode = 2; + return; } const setList = requireArray(sets.parsed?.data, "screenshot-set list"); let set = setList.find( @@ -610,7 +662,8 @@ async function main() { })); if (!created.ok) { console.error(`upload-screenshots: could not create the ${displayType} set (HTTP ${created.status}): ${describeError(created)}`); - process.exit(2); + process.exitCode = 2; + return; } set = requireObject(created.parsed?.data, "screenshot set"); console.error(`upload-screenshots: created the ${displayType} set ${set.id}`); @@ -621,7 +674,8 @@ async function main() { for (const file of files) { if (!existsSync(file)) { console.error(`upload-screenshots: no such file: ${file}`); - process.exit(2); + process.exitCode = 2; + return; } const bytes = readFileSync(file); const fileName = file.split("/").pop(); @@ -637,7 +691,8 @@ async function main() { })); if (!reserved.ok) { console.error(`upload-screenshots: could not reserve ${fileName} (HTTP ${reserved.status}): ${describeError(reserved)}`); - process.exit(2); + process.exitCode = 2; + return; } // The reservation now exists in a set that holds ten entries, so it has // to be dropped on every path that stops here. When the response shape @@ -646,13 +701,15 @@ async function main() { const reservation = reserved.parsed?.data; if (!isPlainObject(reservation)) { console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}; a reservation may be open in set ${set.id}`); - process.exit(2); + process.exitCode = 2; + return; } const screenshot = reservation; if (typeof screenshot.id !== "string" || screenshot.id.length === 0) { console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}; a reservation may be open in set ${set.id}`); if (screenshot.id) await discardReservation(screenshot.id); - process.exit(2); + process.exitCode = 2; + return; } const operations = Array.isArray(screenshot.attributes?.uploadOperations) ? screenshot.attributes.uploadOperations @@ -660,7 +717,8 @@ async function main() { if (operations.length === 0) { console.error(`upload-screenshots: Apple returned no upload operation for ${fileName}`); await discardReservation(screenshot.id); - process.exit(2); + process.exitCode = 2; + return; } for (const operation of operations) { @@ -685,12 +743,14 @@ async function main() { ? "the upload timed out" : `the upload failed (${error && error.message ? error.message : "unknown error"})`; console.error(`upload-screenshots: upload of ${fileName} failed: ${reason}`); - process.exit(2); + process.exitCode = 2; + return; } if (!put.ok) { console.error(`upload-screenshots: upload of ${fileName} failed (HTTP ${put.status})`); await discardReservation(screenshot.id); - process.exit(2); + process.exitCode = 2; + return; } } @@ -700,7 +760,8 @@ async function main() { if (!committed.ok) { console.error(`upload-screenshots: could not commit ${fileName} (HTTP ${committed.status}): ${describeError(committed)}`); await discardReservation(screenshot.id); - process.exit(2); + process.exitCode = 2; + return; } const committedScreenshot = requireObject(committed.parsed?.data, "screenshot commit"); const state = committedScreenshot.attributes?.assetDeliveryState; @@ -711,11 +772,13 @@ async function main() { for (const problem of state.errors) { console.error(` ${problem.code}: ${problem.title} ${problem.detail || ""}`); } - process.exit(3); + process.exitCode = 3; + return; } } console.log(JSON.stringify({ ok: true, setId: set.id, displayType, files })); - process.exit(0); + process.exitCode = 0; + return; } // Highest N already used by the ".N" MARKETING version train @@ -749,12 +812,18 @@ async function main() { const prefix = body; if (!bundleId || !prefix) { console.error("Usage: node asc-api.mjs latest-build-seq "); - process.exit(2); + process.exitCode = 2; + return; } const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); if (!apps.ok || !apps.parsed.data?.[0]) { - console.error(`latest-build-seq: app not found for bundle (HTTP ${apps.status})`); - process.exit(2); + // A request that never reached App Store Connect is not a verdict about + // the app, so it is not reported as "not found". + console.error(apps.networkError + ? `latest-build-seq: ${describeError(apps)}` + : `latest-build-seq: app not found for bundle (HTTP ${apps.status})`); + process.exitCode = 2; + return; } const appId = apps.parsed.data[0].id; @@ -780,7 +849,8 @@ async function main() { const res = await api("GET", url); if (!res.ok) { console.error(`latest-build-seq: preReleaseVersions query failed (HTTP ${res.status})`); - process.exit(2); + process.exitCode = 2; + return; } for (const v of res.parsed.data || []) { // If a specific platform is requested, filter by it. Default to IOS if unspecified. @@ -800,7 +870,8 @@ async function main() { const res = await api("GET", url); if (!res.ok) { console.error(`latest-build-seq: builds query failed (HTTP ${res.status})`); - process.exit(2); + process.exitCode = 2; + return; } for (const b of res.parsed.data || []) { seenBuilds++; @@ -813,7 +884,8 @@ async function main() { `latest-build-seq: ${seenVersions} marketing version(s) + ${seenBuilds} build(s) inspected; highest ${prefix}.N is N=${best}` ); console.log(String(best)); - process.exit(0); + process.exitCode = 0; + return; } // After upload: find THE BUILD THIS RUN JUST UPLOADED, declare export @@ -855,7 +927,8 @@ async function main() { console.error(" this run uploaded. It is required: without it there is no way to tell the"); console.error(" new build from the previous ship, and ASC has no record of the new build"); console.error(" for the first minutes after upload."); - process.exit(2); + process.exitCode = 2; + return; } const totalTimeoutSec = (() => { @@ -869,8 +942,13 @@ async function main() { const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); if (!apps.ok || !apps.parsed.data?.[0]) { - console.error(`ensure-tf-ready: app not found for bundle (HTTP ${apps.status})`); - process.exit(2); + // A request that never reached App Store Connect is not a verdict about + // the app, so it is not reported as "not found". + console.error(apps.networkError + ? `ensure-tf-ready: ${describeError(apps)}` + : `ensure-tf-ready: app not found for bundle (HTTP ${apps.status})`); + process.exitCode = 2; + return; } const appId = apps.parsed.data[0].id; @@ -899,12 +977,14 @@ async function main() { continue; } console.error(`ensure-tf-ready: discovery query failed (HTTP ${res.status})`); - process.exit(2); + process.exitCode = 2; + return; } const rows = res.parsed.data || []; if (rows.length > 1) { console.error(`ensure-tf-ready: ${rows.length} builds match version ${wantBuildVersion} -- ambiguous, refusing to guess`); - process.exit(2); + process.exitCode = 2; + return; } if (rows.length === 1) { build = rows[0]; @@ -921,7 +1001,14 @@ async function main() { let newest = "unknown"; try { const probe = await api("GET", `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1`); - newest = probe.parsed?.data?.[0]?.attributes?.version || "none"; + if (probe.ok) { + newest = probe.parsed?.data?.[0]?.attributes?.version || "none"; + } else { + // The probe did not answer, so what is present is not known. Saying + // "none" would assert that the app has no builds at all, which is a + // different and wrong claim. + console.error(`ensure-tf-ready: the newest-build probe failed: ${describeError(probe)}`); + } } catch { /* diagnostic only */ } console.error( `ensure-tf-ready: build version ${wantBuildVersion} never appeared for ${bundleId} within ${totalTimeoutSec}s; ` + @@ -932,7 +1019,8 @@ async function main() { ok: false, wanted: wantBuildVersion, newestPresent: newest, discoveryTimedOut: true, transientErrors })); - process.exit(4); + process.exitCode = 4; + return; } const buildId = build.id; @@ -948,7 +1036,8 @@ async function main() { })); if (!patch.ok) { console.error(`ensure-tf-ready: compliance patch failed HTTP ${patch.status}`); - process.exit(2); + process.exitCode = 2; + return; } console.error("ensure-tf-ready: declared usesNonExemptEncryption=false"); } @@ -981,16 +1070,18 @@ async function main() { internalBuildState: state, usesNonExemptEncryption: attrs.usesNonExemptEncryption })); - process.exit(0); + process.exitCode = 0; + return; } await sleep(POLL_MS); } console.error("ensure-tf-ready: timed out waiting for IN_BETA_TESTING (upload may still be processing)"); console.log(JSON.stringify({ ok: false, buildId, version, timedOut: true })); - process.exit(3); + process.exitCode = 3; + return; } - const url = path.startsWith("http") ? path : `${API_BASE}${path}`; + const url = path.startsWith("http") ? path : `${apiBase}${path}`; const res = await fetch(url, { method, headers: { @@ -1003,10 +1094,17 @@ async function main() { const text = await res.text(); console.error(`HTTP ${res.status}`); console.log(text); - if (!res.ok) process.exit(2); + if (!res.ok) { process.exitCode = 2; return; } } main().catch((err) => { - console.error(String(err && err.stack ? err.stack : err)); - process.exit(1); + if (err instanceof NamedFailure) { + // The failure named itself, so a stack trace would only be noise. + console.error(err.message); + } else { + console.error(String(err && err.stack ? err.stack : err)); + } + // A failure that already chose its exit code keeps it. + if (!process.exitCode) process.exitCode = 1; + return; }); diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index ffc2f743..b49c5e0e 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -31,6 +31,9 @@ PROJECT_DIR="$REPO_ROOT/ios/CodeCapsCompanion" SCHEME="CodeCapsCompanion" OUT_DIR="${1:-${REPO_ROOT}/.build/appstore-screenshots}" DERIVED_ROOT="${DERIVED_ROOT:-${RUNNER_TEMP:-/tmp}/codecaps-appstore-derived}" +# Where the ink measurement writes its error, so a failure can be reported +# instead of being swallowed by the poll loop. +INK_ERROR_FILE="$DERIVED_ROOT/ink-error.txt" # How long to wait for the app to draw a screen, and how often to look. A # cold simulator took about eighteen seconds to show its first frame and a @@ -49,18 +52,23 @@ RENDER_MIN_PERCENT="${RENDER_MIN_PERCENT:-10.0}" # do, and the same ten percent would be the wrong bar for them. On an iPad # the same content covers a smaller share of a bigger screen, so the settings # bar is lower than the quota bar. -RENDER_MIN_PERCENT_SETTINGS="${RENDER_MIN_PERCENT_SETTINGS:-6.0}" -# A screen that is sparse but finished is still a finished screen. A frame -# that has stopped changing is one, whatever it covers, so a frame above this -# floor is kept once two polls in a row measure the same value. The floor -# sits above the bare navigation bar so a half-drawn frame cannot reach it. -RENDER_STABLE_FLOOR="${RENDER_STABLE_FLOOR:-4.5}" +RENDER_MIN_PERCENT_SETTINGS="${RENDER_MIN_PERCENT_SETTINGS:-5.0}" +# The share of the content region that has to hold ink. The content region is +# everything below the status bar and the navigation bar, so a frame that has +# drawn only its navigation bar measures zero here however much of the frame +# it covers. This is the measurement that tells a finished screen from a +# half-drawn one, and it is the same bar for every screen. +RENDER_MIN_CONTENT_PERCENT="${RENDER_MIN_CONTENT_PERCENT:-0.5}" # How close two consecutive measurements have to be to count as unchanged, -# and how many polls have to agree before the frame is kept. +# and how many polls have to agree before the frame is kept. Holding still is +# an extra condition on top of the thresholds, never a way around them. RENDER_STABLE_DELTA="${RENDER_STABLE_DELTA:-0.2}" RENDER_STABLE_POLLS="${RENDER_STABLE_POLLS:-2}" mkdir -p "$OUT_DIR" +# The ink measurement writes its error here, so the directory has to exist +# before the first frame is measured. +mkdir -p "$DERIVED_ROOT" # App Store Connect accepts these display sizes. The list is Apple's own: # developer.apple.com/help/app-store-connect/reference/screenshot-specifications @@ -199,10 +207,12 @@ PY # The companion takes seconds to render on a cold simulator, and a fixed sleep # captured a blank window more than once. This polls instead: take a frame, # measure how much of it is not background, and stop as soon as the screen -# holds enough of it. A sparse screen is also kept once two polls in a row -# measure the same value, because a screen that has stopped changing is a -# screen that has finished drawing. -# Print the share of the frame that is not background, as a bare number. +# holds enough of it. The frame has to hold its content and to hold still: +# a screen that is still filling in has not finished drawing even when it has +# already cleared the thresholds. +# +# Print the share of the whole frame that is not background, and the share of +# the part below the header, as two bare numbers. # # The measurement is the only thing that tells a drawn screen from a blank # one, so it is worth the seconds it costs, and it is why the background is @@ -210,46 +220,79 @@ PY measure_ink() { # screenshot_ink.py exits 1 when the frame holds less than its own default # minimum, which is exactly the blank-frame case the poll loop below has to - # survive. Under `set -e` an assignment whose command substitution fails - # aborts the whole capture, so the status is swallowed here; an empty - # reading is already handled by the caller's `[ -n "$percent" ]` test. - python3 "$SCRIPT_DIR/screenshot_ink.py" "$1" 2>/dev/null || true + # survive. It exits 2 when the measurement itself failed, which is not a + # blank frame: swallowing that would spend the whole render budget on every + # screen and then report the wrong reason, so it is reported and the run + # stops there. + local status=0 + python3 "$SCRIPT_DIR/screenshot_ink.py" "$1" 2>"$INK_ERROR_FILE" || status=$? + if [ "$status" -ge 2 ]; then + cat "$INK_ERROR_FILE" >&2 + return "$status" + fi + return 0 +} + +# Decide whether a frame holds enough to be a finished screen. +# +# It has to hold its content, and the content has to be below the header: a +# frame that has drawn only its navigation bar covers a few percent of the +# frame, so the whole-frame share on its own cannot tell it from a finished +# screen. The content share can, and it is the same bar for every screen. +frame_clears_thresholds() { + local overall="$1" content="$2" min_overall="$3" min_content="$4" + awk -v p="$overall" -v m="$min_overall" -v c="$content" -v k="$min_content" \ + 'BEGIN { exit !(p >= m && c >= k) }' +} + +# Decide whether two readings are the same, within the allowed difference. +readings_match() { + local a="$1" b="$2" + awk -v p="$a" -v q="$b" -v d="$RENDER_STABLE_DELTA" \ + 'BEGIN { exit !((p - q < 0 ? q - p : p - q) <= d) }' } wait_for_render() { - local udid="$1" frame="$2" min_percent="$3" + local udid="$1" frame="$2" min_overall="$3" min_content="$4" local deadline=$((SECONDS + RENDER_TIMEOUT_SEC)) - local previous="" stable=0 percent="" + local previous_overall="" previous_content="" stable=0 + local overall="" content="" while [ "$SECONDS" -lt "$deadline" ]; do xcrun simctl io "$udid" screenshot --type=jpeg "$frame" >/dev/null 2>&1 || true if [ -s "$frame" ]; then - percent="$(measure_ink "$frame")" - if [ -n "$percent" ]; then - if awk -v p="$percent" -v m="$min_percent" 'BEGIN { exit !(p >= m) }'; then - log "screen holds ${percent}% non-background pixels, at or above ${min_percent}%" - return 0 - fi - # A sparse screen settles instead of filling the frame. Two polls - # that measure the same value mean the drawing stopped, and a frame - # above the floor holds more than a bare navigation bar. - if [ -n "$previous" ] \ - && awk -v p="$percent" -v q="$previous" -v d="$RENDER_STABLE_DELTA" \ - 'BEGIN { exit !((p - q < 0 ? q - p : p - q) <= d) }' \ - && awk -v p="$percent" -v f="$RENDER_STABLE_FLOOR" 'BEGIN { exit !(p >= f) }'; then - stable=$((stable + 1)) - if [ "$stable" -ge "$RENDER_STABLE_POLLS" ]; then - log "screen settled at ${percent}% non-background pixels" - return 0 + local status=0 reading="" + reading="$(measure_ink "$frame")" || status=$? + if [ "$status" -ge 2 ]; then + fail "could not measure the screen on $udid" + fi + if [ -n "$reading" ]; then + overall="${reading%% *}" + content="${reading##* }" + # Holding still is a second condition on top of the thresholds, never + # a way around them: a frame that is still filling in has not + # finished drawing even when it has already cleared them. + if frame_clears_thresholds "$overall" "$content" "$min_overall" "$min_content"; then + if [ -n "$previous_overall" ] \ + && readings_match "$overall" "$previous_overall" \ + && readings_match "$content" "$previous_content"; then + stable=$((stable + 1)) + if [ "$stable" -ge "$RENDER_STABLE_POLLS" ]; then + log "screen settled at ${overall}% overall and ${content}% below the header" + return 0 + fi + else + stable=0 fi else stable=0 fi - previous="$percent" + previous_overall="$overall" + previous_content="$content" fi fi sleep "$RENDER_POLL_SEC" done - fail "the app drew no screen within ${RENDER_TIMEOUT_SEC}s on $udid (last reading ${percent:-none}%)" + fail "the app drew no screen within ${RENDER_TIMEOUT_SEC}s on $udid (last reading ${overall:-none}% overall, ${content:-none}% below the header)" } # Check a captured file against the size App Store Connect accepts and @@ -359,7 +402,7 @@ capture_on() { # App Store Connect rejects a screenshot that has one. It is converted # back to PNG so the upload step can read the size out of the header. local raw="$derived/$label-$name.jpg" - wait_for_render "$udid" "$raw" "$min" + wait_for_render "$udid" "$raw" "$min" "$RENDER_MIN_CONTENT_PERCENT" sips -s format png "$raw" --out "$OUT_DIR/$label-$name.png" >/dev/null log "captured $label $name" done @@ -417,4 +460,9 @@ main() { ls -1 "$OUT_DIR" } -main "$@" +# The capture is run as a script, and its decisions are also exercised by the +# contract test, which sources it. The guard is what makes the second +# possible. +if [ "${CAPTURE_SOURCE_ONLY:-}" != "1" ]; then + main "$@" +fi \ No newline at end of file diff --git a/scripts/ios-fleet/screenshot_ink.py b/scripts/ios-fleet/screenshot_ink.py index 953b89e1..b9749ba8 100644 --- a/scripts/ios-fleet/screenshot_ink.py +++ b/scripts/ios-fleet/screenshot_ink.py @@ -42,6 +42,14 @@ # background and never reaches this, so the slow path is the exception. DOMINANT_BACKGROUND_MINIMUM = 0.10 +# The share of the frame's height that is header: the status bar and the +# navigation bar. Both are drawn before the content, so a frame that has drawn +# only its header has no ink at all below this line. The content region is +# what tells a finished screen from a half-drawn one, because a threshold on +# the whole frame cannot separate "a navigation bar and nothing else" from "a +# screen with a little on it". +HEADER_SHARE = 0.15 + def background_value(channel): """Return the most common byte value in `channel`. @@ -78,13 +86,16 @@ def ink_percent(channels): return worst * 100.0 / len(channels[0]) -def read_bmp_channels(path): - """Return the colour channels of a BMP file, one byte per pixel each. +def read_bmp_frame(path): + """Return the colour channels of a BMP file, with its size. Both 24-bit and 32-bit BMPs are read, in either row order, because that is what `sips` writes depending on whether the source had an alpha channel. The channel order inside a pixel does not matter: the measurement only compares each channel against its own background. + + The rows come back in image order, so the first row of a channel is the + top of the frame and the last is the bottom. """ with open(path, "rb") as handle: data = handle.read() @@ -110,11 +121,55 @@ def read_bmp_channels(path): rows.append((line[0::bytes_per_pixel], line[1::bytes_per_pixel], line[2::bytes_per_pixel])) if not top_down: rows.reverse() - return tuple(b"".join(row[index] for row in rows) for index in range(3)) + channels = tuple(b"".join(row[index] for row in rows) for index in range(3)) + return channels, width, height + + +def read_bmp_channels(path): + """Return the colour channels of a BMP file, one byte per pixel each.""" + channels, _width, _height = read_bmp_frame(path) + return channels + + +def ink_shares(channels, width, height): + """Return the share of the frame, and of its content region, that is ink. + + The overall share counts every pixel. The content share counts only the + pixels below the header, which is where a screen's content lives. + + The two answers together are what tell a finished screen from a half-drawn + one. A frame that has drawn its navigation bar and none of its content + has ink in the header and none at all below it, so a threshold on the + content share rejects it even when the overall share is high enough to + have cleared a threshold on the whole frame. + + As in `ink_percent`, the largest per-channel count wins: it over-counts a + pixel that differs in more than one channel and never under-counts one + that differs in any. + """ + if not channels or width <= 0 or height <= 0: + return 0.0, 0.0 + total = len(channels[0]) + if total <= 0: + return 0.0, 0.0 + start = min(int(height * HEADER_SHARE), max(height - 1, 0)) + region = total - start * width + if region <= 0: + region = 1 + worst_total = 0 + worst_region = 0 + for channel in channels: + if not channel: + continue + background = background_value(channel) + worst_total = max(worst_total, total - channel.count(background)) + segment = channel[start * width:] + worst_region = max(worst_region, len(segment) - segment.count(background)) + return worst_total * 100.0 / total, worst_region * 100.0 / region def measure(path): - """Return the percentage of the frame at `path` that is not background.""" + """Return the overall and content ink percentages of the frame at `path`.""" with tempfile.TemporaryDirectory() as work: converted = os.path.join(work, "frame.bmp") subprocess.run( @@ -122,8 +177,8 @@ def measure(path): check=True, capture_output=True, ) - channels = read_bmp_channels(converted) - return ink_percent(channels) + channels, width, height = read_bmp_frame(converted) + return ink_shares(channels, width, height) def main(argv): @@ -137,19 +192,20 @@ def main(argv): ) args = parser.parse_args(argv[1:]) try: - percent = measure(args.image) + overall, content = measure(args.image) except (OSError, ValueError, subprocess.CalledProcessError) as error: print(f"screenshot_ink: {error}", file=sys.stderr) return 2 print( - f"screenshot_ink: {os.path.basename(args.image)} holds {percent:.2f}% non-background pixels", + f"screenshot_ink: {os.path.basename(args.image)} holds {overall:.2f}% ink overall " + f"and {content:.2f}% below the header", file=sys.stderr, ) - # The bare number goes to stdout so the capture script can read it and + # The two numbers go to stdout so the capture script can read them and # compare two polls. The human line stays on stderr, so it never becomes # part of a captured value. - print(f"{percent:.4f}") - return 0 if percent >= args.min_percent else 1 + print(f"{overall:.4f} {content:.4f}") + return 0 if overall >= args.min_percent else 1 if __name__ == "__main__": diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs new file mode 100644 index 00000000..dd2d7810 --- /dev/null +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -0,0 +1,153 @@ +// The App Store Connect client reports its own failures correctly. +// +// Two things go wrong easily here and both send the operator in the wrong +// direction. A host override that is not the production host has to be +// refused without a stack trace, because the stack trace names a module scope +// rather than the thing the operator has to change. And a request that never +// reached App Store Connect is not a verdict about the app, so it must not be +// reported as "app not found". +// +// These tests run the real client against a local server, or against a closed +// port, with a synthetic key. No network, no fleet-secrets, no real secret. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const script = join(here, "asc-api.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-client-test-")); + +const keyPath = join(root, "synthetic.p8"); +writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" })); + +const PROD = "https://api.appstoreconnect.apple.com"; + +function run(args, env = {}) { + return new Promise((resolve) => { + const child = spawn("node", [script, ...args], { + env: { + ...process.env, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_PATH: keyPath, + ...env + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +test("the production host is accepted when it is pinned explicitly", async () => { + // No arguments, so the run stops at the usage message and never touches + // the network. What matters is that the host was accepted at all: the + // refusal would exit 2 with a different message. + const { code, stderr } = await run([], { ASC_API_BASE: PROD }); + assert.equal(code, 1, `expected the usage exit, got ${code}: ${stderr}`); + assert.ok( + !/loopback/.test(stderr), + `pinning the production host was refused:\n${stderr}` + ); + assert.ok(/Usage: node asc-api.mjs/.test(stderr), "the usage message was not printed"); +}); + +test("a host that is neither the production host nor loopback is refused by name", async () => { + const { code, stderr } = await run([], { ASC_API_BASE: "https://not-the-real-host.example.com" }); + assert.equal(code, 2, `expected a named refusal, got ${code}: ${stderr}`); + assert.ok( + /must be the production host or a loopback address/.test(stderr), + `the refusal did not say what to change:\n${stderr}` + ); + assert.ok( + !/at Object\.|at Module\.|at file:/.test(stderr), + `the refusal came with a stack trace:\n${stderr}` + ); +}); + +test("a trailing slash on the override does not double the path separator", async () => { + const paths = []; + const server = createServer((req, res) => { + paths.push(req.url); + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("no"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}/`; + try { + await run(["GET", "/v1/apps"], { ASC_API_BASE: base }); + } finally { + server.close(); + } + assert.ok(paths.length > 0, "the server never received a request"); + for (const path of paths) { + assert.ok( + !path.includes("//"), + `the request path has a doubled separator: ${path}` + ); + } +}); + +test("a request that never reached App Store Connect is not reported as app not found", async () => { + // Port 1 on loopback has nothing listening, so the connection is refused + // and the request never leaves the machine. + const { code, stderr } = await run( + ["latest-build-seq", "com.example.nothing", "1.0"], + { ASC_API_BASE: "http://127.0.0.1:1" } + ); + assert.equal(code, 2, `expected a failed lookup, got ${code}: ${stderr}`); + assert.ok( + /never reached App Store Connect/.test(stderr), + `a transport failure was reported as a verdict about the app:\n${stderr}` + ); + assert.ok( + !/app not found/.test(stderr), + `a transport failure was reported as "app not found":\n${stderr}` + ); +}); + +test("the message that explains a failure is not lost on the way out", async () => { + // stderr is a pipe here, and a pending write to a pipe is not flushed by + // process.exit(), so the diagnostic could be dropped entirely. + const { code, stderr } = await run([], { ASC_API_BASE: "https://not-the-real-host.example.com" }); + assert.equal(code, 2); + assert.ok( + stderr.trim().length > 0, + "the failure left no message behind: the exit discarded the write" + ); +}); + +test("a large payload is not truncated on the way out", async () => { + // The failure body is deliberately bigger than a pipe buffer, so a write + // that is still queued when the process ends is visibly cut short. This + // is the shape of the bug: the caller gets half an answer and no idea that + // anything is missing. + const body = "x".repeat(1024 * 1024); + const server = createServer((req, res) => { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end(body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["GET", "/v1/apps"], { ASC_API_BASE: base }); + } finally { + server.close(); + } + assert.equal(result.code, 2, "the failed call has to exit non-zero"); + assert.ok( + result.stdout.length >= body.length, + `the payload was cut short: ${result.stdout.length} of ${body.length} bytes arrived` + ); +}); \ No newline at end of file diff --git a/scripts/ios-fleet/test-capture-script.sh b/scripts/ios-fleet/test-capture-script.sh index fdb2dfc0..edae8569 100755 --- a/scripts/ios-fleet/test-capture-script.sh +++ b/scripts/ios-fleet/test-capture-script.sh @@ -53,17 +53,75 @@ pass "the capture waits for a drawn frame" # lets a half-drawn frame through. The settings screens need their own bar. grep -q 'RENDER_MIN_PERCENT_SETTINGS' "$CAPTURE" \ || fail "the capture script has no separate minimum for the settings screens" -grep -q 'wait_for_render "$udid" "$raw" "$min"' "$CAPTURE" \ - || fail "the capture script does not give each screen its own minimum" +# shellcheck disable=SC2016 # the pattern is matched literally, not expanded +grep -q 'wait_for_render "$udid" "$raw" "$min" "$RENDER_MIN_CONTENT_PERCENT"' "$CAPTURE" \ + || fail "the capture script does not give each screen its own minimum and the content bar" pass "each screen is measured against a minimum that fits it" -# A screen that is sparse but finished never reaches the high bar, so the -# wait has to be able to keep a frame that has stopped changing. -grep -q 'RENDER_STABLE_FLOOR' "$CAPTURE" \ - || fail "the capture script cannot keep a sparse screen that has settled" +# A frame that has drawn only its navigation bar covers a few percent of the +# frame, so a threshold on the whole frame alone cannot tell it from a +# finished screen. The content region below the header is what can, and it +# has to be part of the gate rather than a separate check. +grep -q 'RENDER_MIN_CONTENT_PERCENT' "$CAPTURE" \ + || fail "the capture script does not measure the content region" +grep -q 'p >= m && c >= k' "$CAPTURE" \ + || fail "the capture script does not require the content region to hold ink" +pass "the content region below the header is part of the gate" + +# Holding still is a second condition on top of the thresholds, never a way +# around them: a frame that is still filling in has not finished drawing even +# when it has already cleared them. +if grep -q 'RENDER_STABLE_FLOOR' "$CAPTURE"; then + fail "the capture script has a stable-frame floor that can bypass the minimum" +fi +grep -q 'RENDER_STABLE_DELTA' "$CAPTURE" \ + || fail "the capture script does not require the frame to hold still" grep -q 'RENDER_STABLE_POLLS' "$CAPTURE" \ || fail "the capture script does not require the frame to hold still" -pass "a sparse screen that has settled is kept" +pass "the settled path cannot bypass the minimum" + +# A measurement that failed is not a blank frame. Swallowing it would spend +# the whole render budget on every screen and then report the wrong reason. +grep -q 'INK_ERROR_FILE' "$CAPTURE" \ + || fail "the capture script discards the ink measurement error" +grep -q 'status" -ge 2' "$CAPTURE" \ + || fail "the capture script does not fail fast on a failed measurement" +pass "a failed measurement stops the capture instead of timing out" + +# The decisions the capture makes are exercised against the real numbers, not +# only checked for their presence in the source. The script is sourced with +# the run guarded off, so the functions can be called directly. +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +gate() { + # gate -> exit 0 when drawn + ( + cd "$work" + OUT_DIR="$work/out" DERIVED_ROOT="$work/derived" CAPTURE_SOURCE_ONLY=1 \ + bash -c ". '$CAPTURE'; frame_clears_thresholds \"\$1\" \"\$2\" \"\$3\" \"\$4\"" \ + _ "$1" "$2" "$3" "$4" + ) >/dev/null 2>&1 +} + +# A navigation bar and no content: about four percent of the frame, and none +# of it below the header. It has to be rejected for every screen, which is +# the case a threshold on the whole frame alone got wrong. +gate 4.6 0.0 10.0 0.5 && fail "a navigation bar with no content passed the quota-list gate" +gate 4.6 0.0 5.0 0.5 && fail "a navigation bar with no content passed the settings gate" +gate 4.6 0.0 4.5 0.4 && fail "a navigation bar with no content passed a low gate" +pass "a navigation bar with no content never passes the gate" + +# A half-drawn screen: some content, but the content share is under the bar. +gate 12.0 0.2 10.0 0.5 && fail "a half-drawn screen passed the quota-list gate" +gate 12.0 0.2 5.0 0.5 && fail "a half-drawn screen passed the settings gate" +pass "a half-drawn screen never passes the gate" + +# The finished quota list and the finished settings sheet both pass. +gate 43.0 12.0 10.0 0.5 || fail "the finished quota list did not pass the gate" +gate 6.2 1.1 5.0 0.5 || fail "the finished settings sheet did not pass the gate" +# Exactly on the bar still passes. +gate 10.0 0.5 10.0 0.5 || fail "a frame exactly on the bar did not pass the gate" +pass "a finished screen passes the gate" # Both simulators run the same runtime and the same product, so the app is # built once and installed on both. A second build compiles the companion and @@ -74,4 +132,4 @@ $builds" grep -q 'APP_PATH=' "$CAPTURE" || fail "the capture script has no shared app path" pass "the app is built once and installed on both simulators" -echo "capture script contract: 7 checks passed" +echo "capture script contract: 12 checks passed" diff --git a/scripts/ios-fleet/test_screenshot_ink.py b/scripts/ios-fleet/test_screenshot_ink.py index e7966747..abe96896 100644 --- a/scripts/ios-fleet/test_screenshot_ink.py +++ b/scripts/ios-fleet/test_screenshot_ink.py @@ -16,7 +16,28 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from screenshot_ink import background_value, ink_percent, read_bmp_channels # noqa: E402 +from screenshot_ink import ( # noqa: E402 + background_value, + ink_percent, + ink_shares, + read_bmp_channels, + read_bmp_frame, +) + + +def header_only_frame(width=200, height=200, header_rows=20): + """A frame with a navigation bar and nothing below it. + + This is the shape of a screen that has drawn its header and is still + waiting for its content, and it is the case the whole-frame threshold + cannot see: it covers a few percent of the frame, but none of the content + region. + """ + rows = [[(255, 255, 255) for _ in range(width)] for _ in range(height)] + for y in range(header_rows): + for x in range(width): + rows[y][x] = (136, 148, 13) + return rows def build_bmp(width, height, rows, bits=24, top_down=True): @@ -163,5 +184,56 @@ def test_a_drawn_screen_clears_the_threshold(self): self.assertGreater(ink_percent(channels), 1.0) +class InkSharesTests(unittest.TestCase): + def test_a_header_only_frame_has_ink_but_no_content(self): + # The whole-frame share sees the navigation bar. The content share + # does not, because the bar is above the content region. That is the + # difference the capture gate depends on. + with tempfile.TemporaryDirectory() as work: + path = os.path.join(work, "frame.bmp") + with open(path, "wb") as handle: + handle.write(build_bmp(200, 200, header_only_frame())) + channels, width, height = read_bmp_frame(path) + overall, content = ink_shares(channels, width, height) + self.assertGreater(overall, 0.0) + self.assertEqual(content, 0.0) + + def test_a_drawn_screen_holds_content_below_the_header(self): + with tempfile.TemporaryDirectory() as work: + path = os.path.join(work, "frame.bmp") + with open(path, "wb") as handle: + handle.write(build_bmp(200, 200, drawn_frame())) + channels, width, height = read_bmp_frame(path) + overall, content = ink_shares(channels, width, height) + self.assertGreater(overall, 0.0) + self.assertGreater(content, 0.0) + + def test_the_content_share_is_a_share_of_the_content_region(self): + # The content region is the part below the header, so it excludes the + # ink in the header and is a share of a smaller area. The card fills + # most of the content region, so the two numbers stay close. + with tempfile.TemporaryDirectory() as work: + path = os.path.join(work, "frame.bmp") + with open(path, "wb") as handle: + handle.write(build_bmp(200, 200, drawn_frame())) + channels, width, height = read_bmp_frame(path) + overall, content = ink_shares(channels, width, height) + self.assertLess(content, overall, "the header region holds ink, so it is excluded") + self.assertGreater(content, overall * 0.7, "the card fills most of the content region") + + def test_no_channels_measures_nothing(self): + self.assertEqual(ink_shares([], 100, 100), (0.0, 0.0)) + + def test_read_bmp_frame_reports_the_size(self): + with tempfile.TemporaryDirectory() as work: + path = os.path.join(work, "frame.bmp") + with open(path, "wb") as handle: + handle.write(build_bmp(7, 11, blank_frame(width=7, height=11, status_bar_pixels=2))) + channels, width, height = read_bmp_frame(path) + self.assertEqual(width, 7) + self.assertEqual(height, 11) + self.assertEqual(len(channels[0]), 7 * 11) + + if __name__ == "__main__": unittest.main() From debf87aff9e62d6ba5c2e7e3f2843dd319966dca Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:13:05 -0500 Subject: [PATCH 17/50] CC-169: close the third round of screenshot-tooling findings Five findings from the last review pass, all addressed here. A timeout and a transport failure are different causes. api() now says which one happened, and describeError says so too: a call that never answered is reported as a timeout, and only a call that never got there is reported as never having reached App Store Connect. The timeout was computed and then thrown away, so the operator was sent to check the network when the real cause was a slow App Store Connect. A rejected key is no longer reported as a missing app. Every failure the service reports now goes through describeError, and "app not found" is only said when the response was fine and carried no app. The newest-build probe checks the shape of the response before reading it. A malformed answer leaves the reading unknown instead of becoming a build version. A BMP with a short header is rejected by name. struct.error is not a ValueError, so it escaped the handler and landed on exit 1, which the capture reads as a blank frame, and the capture then spent its whole render budget on a frame it could never measure. The reader now says the file is too short, and struct.error is caught as well. The contract test no longer passes its own values positionally. The capture script reads its output directory from "$1", so the gate values were landing there and leaving stray directories behind. They travel in the environment now, and the capture takes OUT_DIR from the environment when there is no argument. A check asserts that sourcing the capture leaves nothing outside the directory it was given. New in test-asc-client.mjs: a call that never answers is reported as a timeout, and a rejected key is reported as a rejected key. The request timeout is overridable so a test can drive a real one. --- scripts/ios-fleet/asc-api.mjs | 60 ++++++++++++++----- .../ios-fleet/capture-appstore-screenshots.sh | 6 +- scripts/ios-fleet/screenshot_ink.py | 18 +++++- scripts/ios-fleet/test-asc-client.mjs | 59 ++++++++++++++++++ scripts/ios-fleet/test-capture-script.sh | 18 +++++- scripts/ios-fleet/test_screenshot_ink.py | 27 +++++++++ 6 files changed, 168 insertions(+), 20 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 67d46ab4..d9e30c91 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -415,7 +415,14 @@ async function main() { // How long a single App Store Connect call may take. Node's fetch has no // default timeout, so a call that never answers would hang the script with // the reservation still pending in App Store Connect. - const REQUEST_TIMEOUT_MS = 120_000; + // + // The value is overridable so a test can drive a real timeout without + // waiting two minutes for one. + const REQUEST_TIMEOUT_MS = (() => { + const raw = process.env.ASC_REQUEST_TIMEOUT_MS; + const n = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(n) && n > 0 ? n : 120_000; + })(); // Describe a failed response without ever printing what it holds. // @@ -427,7 +434,12 @@ async function main() { // piece of the key. function describeError(res) { if (res.networkError) { - return "the request never reached App Store Connect"; + // A timeout and a transport failure have different causes, and the + // operator needs the one that is true: one means App Store Connect was + // slow, the other that the request never got there. + return res.timedOut + ? "the request timed out before App Store Connect answered" + : "the request never reached App Store Connect"; } if (res.status === 401 || res.status === 403) { return "App Store Connect rejected the key: check ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 in Fleet-OPS prod /asc"; @@ -507,6 +519,9 @@ async function main() { // looks at the status would read this as a server verdict, so the // transport failure is marked and reported as one. networkError: true, + // A timeout and a refused connection are different causes, and the + // message has to name the one that happened. + timedOut: Boolean(timedOut), text: "", parsed: { errors: [{ @@ -816,12 +831,15 @@ async function main() { return; } const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); - if (!apps.ok || !apps.parsed.data?.[0]) { - // A request that never reached App Store Connect is not a verdict about - // the app, so it is not reported as "not found". - console.error(apps.networkError - ? `latest-build-seq: ${describeError(apps)}` - : `latest-build-seq: app not found for bundle (HTTP ${apps.status})`); + if (!apps.ok) { + // Every failure the service reports goes through describeError, so a + // rejected key is never read as a bundle id that does not exist. + console.error(`latest-build-seq: ${describeError(apps)}`); + process.exitCode = 2; + return; + } + if (!isPlainObject(apps.parsed?.data?.[0])) { + console.error(`latest-build-seq: no app in App Store Connect has the bundle id ${bundleId}`); process.exitCode = 2; return; } @@ -941,12 +959,15 @@ async function main() { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); - if (!apps.ok || !apps.parsed.data?.[0]) { - // A request that never reached App Store Connect is not a verdict about - // the app, so it is not reported as "not found". - console.error(apps.networkError - ? `ensure-tf-ready: ${describeError(apps)}` - : `ensure-tf-ready: app not found for bundle (HTTP ${apps.status})`); + if (!apps.ok) { + // Every failure the service reports goes through describeError, so a + // rejected key is never read as a bundle id that does not exist. + console.error(`ensure-tf-ready: ${describeError(apps)}`); + process.exitCode = 2; + return; + } + if (!isPlainObject(apps.parsed?.data?.[0])) { + console.error(`ensure-tf-ready: no app in App Store Connect has the bundle id ${bundleId}`); process.exitCode = 2; return; } @@ -1002,7 +1023,16 @@ async function main() { try { const probe = await api("GET", `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1`); if (probe.ok) { - newest = probe.parsed?.data?.[0]?.attributes?.version || "none"; + // The response is the service's, so its shape is checked before any + // of it is read. A malformed answer has to leave the reading + // unknown, not become a build version. + const rows = Array.isArray(probe.parsed?.data) ? probe.parsed.data : []; + const first = rows[0]; + const version = isPlainObject(first) && isPlainObject(first.attributes) + && typeof first.attributes.version === "string" + ? first.attributes.version + : ""; + newest = version || "none"; } else { // The probe did not answer, so what is present is not known. Saying // "none" would assert that the app has no builds at all, which is a diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh index b49c5e0e..6258192d 100755 --- a/scripts/ios-fleet/capture-appstore-screenshots.sh +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -29,7 +29,11 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$REPO_ROOT/ios/CodeCapsCompanion" SCHEME="CodeCapsCompanion" -OUT_DIR="${1:-${REPO_ROOT}/.build/appstore-screenshots}" +# Where the PNGs are written. The argument wins, then the environment, then +# the default. The environment comes second so a caller with nowhere else to +# write can say so, and so a script that sources this one to reuse its +# functions does not scribble into the repository. +OUT_DIR="${1:-${OUT_DIR:-${REPO_ROOT}/.build/appstore-screenshots}}" DERIVED_ROOT="${DERIVED_ROOT:-${RUNNER_TEMP:-/tmp}/codecaps-appstore-derived}" # Where the ink measurement writes its error, so a failure can be reported # instead of being swallowed by the poll loop. diff --git a/scripts/ios-fleet/screenshot_ink.py b/scripts/ios-fleet/screenshot_ink.py index b9749ba8..2a08289c 100644 --- a/scripts/ios-fleet/screenshot_ink.py +++ b/scripts/ios-fleet/screenshot_ink.py @@ -101,6 +101,11 @@ def read_bmp_frame(path): data = handle.read() if data[:2] != b"BM": raise ValueError(f"{path} is not a BMP file") + # The header fields read below end at byte 30. A file shorter than that + # cannot carry them, and unpacking them anyway raises struct.error, which + # is not a ValueError and used to escape as an unhandled traceback. + if len(data) < 30: + raise ValueError(f"{path} is {len(data)} bytes, too short to be a BMP header") offset = struct.unpack_from(" { + // The server accepts the connection and then says nothing, so the call + // has to be aborted. A timeout and a transport failure have different + // causes: one means App Store Connect was slow, the other that the + // request never got there, and the operator needs the one that is true. + const sockets = []; + const server = createServer((req, res) => { + // Never answer. + }); + server.on("connection", (socket) => sockets.push(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { + ASC_API_BASE: base, + ASC_REQUEST_TIMEOUT_MS: "400" + }); + } finally { + for (const socket of sockets) socket.destroy(); + server.close(); + } + assert.equal(result.code, 2, `expected a failed lookup, got ${result.code}: ${result.stderr}`); + assert.ok( + /timed out/.test(result.stderr), + `a timeout was not reported as one:\n${result.stderr}` + ); + assert.ok( + !/never reached App Store Connect/.test(result.stderr), + `a timeout was reported as a transport failure:\n${result.stderr}` + ); +}); + +test("a rejected key is reported as a rejected key, not as a missing app", async () => { + // The credential is what failed, so saying the bundle id is unknown sends + // the operator after the wrong thing entirely. + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { ASC_API_BASE: base }); + } finally { + server.close(); + } + assert.equal(result.code, 2, `expected a failed lookup, got ${result.code}: ${result.stderr}`); + assert.ok( + /rejected the key/.test(result.stderr), + `a rejected key was not named:\n${result.stderr}` + ); + assert.ok( + !/app not found/.test(result.stderr), + `a rejected key was reported as a missing app:\n${result.stderr}` + ); +}); + test("a large payload is not truncated on the way out", async () => { // The failure body is deliberately bigger than a pipe buffer, so a write // that is still queued when the process ends is visibly cut short. This diff --git a/scripts/ios-fleet/test-capture-script.sh b/scripts/ios-fleet/test-capture-script.sh index edae8569..1c5e5dc0 100755 --- a/scripts/ios-fleet/test-capture-script.sh +++ b/scripts/ios-fleet/test-capture-script.sh @@ -91,6 +91,10 @@ pass "a failed measurement stops the capture instead of timing out" # The decisions the capture makes are exercised against the real numbers, not # only checked for their presence in the source. The script is sourced with # the run guarded off, so the functions can be called directly. +# +# The gate values travel in the environment, not as positional arguments: the +# script reads its output directory from "$1", so passing the numbers that way +# would set OUT_DIR to a number and leave stray directories behind. work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT gate() { @@ -98,8 +102,8 @@ gate() { ( cd "$work" OUT_DIR="$work/out" DERIVED_ROOT="$work/derived" CAPTURE_SOURCE_ONLY=1 \ - bash -c ". '$CAPTURE'; frame_clears_thresholds \"\$1\" \"\$2\" \"\$3\" \"\$4\"" \ - _ "$1" "$2" "$3" "$4" + GATE_OVERALL="$1" GATE_CONTENT="$2" GATE_MIN_OVERALL="$3" GATE_MIN_CONTENT="$4" \ + bash -c ". '$CAPTURE'; frame_clears_thresholds \"\$GATE_OVERALL\" \"\$GATE_CONTENT\" \"\$GATE_MIN_OVERALL\" \"\$GATE_MIN_CONTENT\"" ) >/dev/null 2>&1 } @@ -123,6 +127,14 @@ gate 6.2 1.1 5.0 0.5 || fail "the finished settings sheet did not pass the gate" gate 10.0 0.5 10.0 0.5 || fail "a frame exactly on the bar did not pass the gate" pass "a finished screen passes the gate" +# Sourcing the capture must not write outside the directory the test set up. +# The script reads its output directory from "$1", so a helper that passes its +# own values positionally would silently send the output somewhere else. +stray="$(find "$work" -mindepth 1 -maxdepth 1 -not -name out -not -name derived -not -name 'ink-error.txt' 2>/dev/null)" +[[ -z "$stray" ]] || fail "sourcing the capture left directories behind: +$stray" +pass "sourcing the capture writes only inside the directory it was given" + # Both simulators run the same runtime and the same product, so the app is # built once and installed on both. A second build compiles the companion and # the shared QuotaCore files from scratch again, for nothing. @@ -132,4 +144,4 @@ $builds" grep -q 'APP_PATH=' "$CAPTURE" || fail "the capture script has no shared app path" pass "the app is built once and installed on both simulators" -echo "capture script contract: 12 checks passed" +echo "capture script contract: 13 checks passed" diff --git a/scripts/ios-fleet/test_screenshot_ink.py b/scripts/ios-fleet/test_screenshot_ink.py index abe96896..edc8a53d 100644 --- a/scripts/ios-fleet/test_screenshot_ink.py +++ b/scripts/ios-fleet/test_screenshot_ink.py @@ -235,5 +235,32 @@ def test_read_bmp_frame_reports_the_size(self): self.assertEqual(len(channels[0]), 7 * 11) +class MainExitCodeTests(unittest.TestCase): + def test_a_bmp_with_a_short_header_is_rejected_by_name(self): + # A short header cannot carry the fields the reader needs. It has + # to be a ValueError with a message, not a struct.error, because + # struct.error is not a ValueError and used to escape the handler + # and land on exit 1, which the capture reads as a blank frame. + with tempfile.TemporaryDirectory() as work: + path = os.path.join(work, "frame.bmp") + with open(path, "wb") as handle: + handle.write(b"BM" + b"\x00" * 8) + with self.assertRaises(ValueError) as caught: + read_bmp_channels(path) + self.assertIn("too short", str(caught.exception)) + + def test_a_file_that_cannot_be_measured_is_a_failed_measurement(self): + import screenshot_ink + + with tempfile.TemporaryDirectory() as work: + path = os.path.join(work, "frame.bmp") + with open(path, "wb") as handle: + handle.write(b"not a bitmap at all") + # Exit 2 is "the measurement failed". Exit 1 would be read as + # "the frame is blank", and the capture would then spend its + # whole render budget on a frame it could never measure. + self.assertEqual(screenshot_ink.main(["screenshot_ink", path]), 2) + + if __name__ == "__main__": unittest.main() From 8e42882a861715a47c897cfd3eb3d6fbddd86176 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:18:25 +0000 Subject: [PATCH 18/50] autofix: keep a malformed newest-build probe out of "none" The shape guard added in debf87a folded a malformed-but-successful probe into "none", because a body that fails the guards produced an empty rows array and `version || "none"` then asserted that the app has no builds at all. That is the same misreport the original finding was about, one level up. The three readings now stay distinct: a version is the actual newest build, "none" is the service reporting an empty array, and "unknown" is an answer that could not be read or did not have the expected shape. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index d9e30c91..88778b75 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -1024,15 +1024,22 @@ async function main() { const probe = await api("GET", `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1`); if (probe.ok) { // The response is the service's, so its shape is checked before any - // of it is read. A malformed answer has to leave the reading - // unknown, not become a build version. - const rows = Array.isArray(probe.parsed?.data) ? probe.parsed.data : []; - const first = rows[0]; - const version = isPlainObject(first) && isPlainObject(first.attributes) - && typeof first.attributes.version === "string" - ? first.attributes.version - : ""; - newest = version || "none"; + // of it is read. Three states stay distinct: a version is the + // actual newest build, "none" is the service saying there is no + // build at all, and "unknown" is an answer that could not be read. + // A malformed body must not fold into "none". + const rows = Array.isArray(probe.parsed?.data) ? probe.parsed.data : null; + if (rows === null) { + newest = "unknown"; + } else if (rows.length === 0) { + newest = "none"; + } else { + const first = rows[0]; + newest = isPlainObject(first) && isPlainObject(first.attributes) + && typeof first.attributes.version === "string" + ? first.attributes.version + : "unknown"; + } } else { // The probe did not answer, so what is present is not known. Saying // "none" would assert that the app has no builds at all, which is a From db35f69c33775827e383a99228ec766ef7301567 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:22:14 -0500 Subject: [PATCH 19/50] CC-169: identify the key in play with the fleet's masked preview The review asked for the masked first-eight/last-four preview of the key when App Store Connect rejects a credential, and I had pushed back on it. The push-back was wrong. The preview is the owner's rule of 2026-07-16, and the other fleet apps already use the same shape, maskApiKeyPreview in Socratic.Trade and buildKeyPreview in Usage Monitor. The reason it exists is that there is otherwise no way to tell which of several keys is the one in play, and pasting a value to find out is never the answer. So the rejection message now carries the fleet's masked preview of the key in play, and the key id beside it. The key id is a public identifier: it travels in the header of every request to App Store Connect, and it is the part that actually says which credential was refused. Nothing else about the handling of a credential changes. The response body is still never echoed, and the preview is computed from the key on disk rather than read out of anything the service said. test-asc-client.mjs asserts the preview and the key id are in the message. --- scripts/ios-fleet/asc-api.mjs | 21 ++++++++++++++++++++- scripts/ios-fleet/test-asc-client.mjs | 16 +++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 88778b75..dac773c1 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -29,6 +29,20 @@ import { join } from "node:path"; // own, without a stack trace, and keeps the exit code the failure set. class NamedFailure extends Error {} +// The fleet's masked key preview: the first eight and the last four +// characters, so a key can be identified without any of it being published. +// +// This is the owner's rule of 2026-07-16 and the same shape the other fleet +// apps use (maskApiKeyPreview, buildKeyPreview). It exists because there is +// otherwise no way to tell which of several keys is the one in play, and +// pasting a value to find out is never the answer. +function maskKeyPreview(value) { + if (typeof value !== "string" || value.length < 12) { + return "(too short to preview)"; + } + return `${value.slice(0, 8)}โ€ฆ${value.slice(-4)}`; +} + // The App Store Connect host. const PRODUCTION_API_BASE = "https://api.appstoreconnect.apple.com"; @@ -442,7 +456,12 @@ async function main() { : "the request never reached App Store Connect"; } if (res.status === 401 || res.status === 403) { - return "App Store Connect rejected the key: check ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 in Fleet-OPS prod /asc"; + // The key id is a public identifier: it travels in the header of every + // request to App Store Connect. Together with the fleet's masked + // preview of the key in play it says which credential was rejected, + // which is the question an operator actually has, without publishing + // any of the key. + return `App Store Connect rejected the key: check ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 in Fleet-OPS prod /asc (key id ${keyId}, key in play ${maskKeyPreview(privateKeyPem)})`; } const parts = []; const errors = res.parsed && Array.isArray(res.parsed.errors) ? res.parsed.errors : []; diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs index 2d88ddb7..081a82e6 100644 --- a/scripts/ios-fleet/test-asc-client.mjs +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -11,7 +11,7 @@ // port, with a synthetic key. No network, no fleet-secrets, no real secret. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { generateKeyPairSync } from "node:crypto"; import { tmpdir } from "node:os"; @@ -184,6 +184,20 @@ test("a rejected key is reported as a rejected key, not as a missing app", async !/app not found/.test(result.stderr), `a rejected key was reported as a missing app:\n${result.stderr}` ); + // The fleet's masked preview, so the key in play can be identified without + // any of it being published. + const pem = readFileSync(keyPath, "utf8"); + const expectedPreview = `${pem.slice(0, 8)}โ€ฆ${pem.slice(-4)}`; + assert.ok( + result.stderr.includes(expectedPreview), + `the failure did not carry the masked key preview ${expectedPreview}:\n${result.stderr}` + ); + // The key id is public: it travels in every request to App Store + // Connect, and it is the part that says which credential was refused. + assert.ok( + result.stderr.includes("SYNTHETICKEYID1"), + `the failure did not name the key id:\n${result.stderr}` + ); }); test("a large payload is not truncated on the way out", async () => { From 22a94a943e0d0c8781f5fb2cf07f7fe518593219 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:52:49 +0000 Subject: [PATCH 20/50] autofix: keep the HTTP status and validate the app id on an ASC lookup Three findings from the review round, all in scripts/ios-fleet/asc-api.mjs. The app-lookup failure branch had replaced `app not found for bundle (HTTP N)` with describeError(apps), which returns the JSON API detail and never the status for anything other than 401/403. ship-testflight.sh:441-445 documents these `latest-build-seq:` lines as carrying Apple's HTTP status, and forwards them to the log; without it a 429, a 500 and a 404 all read the same. The status is now appended, omitted only for a transport failure, which has none. The same branch's shape guard checked that the app entry was an object but not that it carried a usable id, so `apps.parsed.data[0].id` could be undefined and every query below would send filter[app]=undefined and fail with HTTP 400 -- reported as a query the client built wrong, the same misdiagnosis the guard was added to prevent. The id is now required to be a non-empty string, in both the latest-build-seq and the ensure-tf-ready branch, which had the identical pattern. ASC_REQUEST_TIMEOUT_MS is now clamped to 2^31-1. AbortSignal.timeout does not accept a larger delay: it throws a RangeError inside api(), where the generic catch reports it as a transport failure, and at the raw fetch on the ensure-tf-ready path it escapes main() as a raw stack trace with exit 1. test-asc-client.mjs (8), test-asc-upload.mjs (5) and test-asc-credentials.mjs (7) pass. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 36 ++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index dac773c1..8d50338e 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -435,7 +435,11 @@ async function main() { const REQUEST_TIMEOUT_MS = (() => { const raw = process.env.ASC_REQUEST_TIMEOUT_MS; const n = raw ? Number.parseInt(raw, 10) : Number.NaN; - return Number.isFinite(n) && n > 0 ? n : 120_000; + if (!Number.isFinite(n) || n <= 0) return 120_000; + // AbortSignal.timeout does not accept a delay above 2^31-1: it either + // throws a RangeError or silently rounds the delay down, so an + // unclamped override would abort every request immediately. + return Math.min(n, 2_147_483_647); })(); // Describe a failed response without ever printing what it holds. @@ -852,8 +856,12 @@ async function main() { const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); if (!apps.ok) { // Every failure the service reports goes through describeError, so a - // rejected key is never read as a bundle id that does not exist. - console.error(`latest-build-seq: ${describeError(apps)}`); + // rejected key is never read as a bundle id that does not exist. The + // status stays in the message: ship-testflight.sh logs these lines + // because the status is the only thing that explains a failed lookup, + // and describeError alone cannot tell a rate limit from a bad request. + const status = apps.networkError ? "" : ` (HTTP ${apps.status})`; + console.error(`latest-build-seq: ${describeError(apps)}${status}`); process.exitCode = 2; return; } @@ -863,6 +871,14 @@ async function main() { return; } const appId = apps.parsed.data[0].id; + // The id is what every query below filters on, so an app record without + // one would send filter[app]=undefined and produce an HTTP 400 that reads + // as a query the client built wrong. + if (typeof appId !== "string" || appId.length === 0) { + console.error(`latest-build-seq: the app record for ${bundleId} has no usable id`); + process.exitCode = 2; + return; + } // Escape the prefix so "1.0" cannot match "120" via the regex dot. const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -980,8 +996,10 @@ async function main() { const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); if (!apps.ok) { // Every failure the service reports goes through describeError, so a - // rejected key is never read as a bundle id that does not exist. - console.error(`ensure-tf-ready: ${describeError(apps)}`); + // rejected key is never read as a bundle id that does not exist. The + // status stays in the message for the same reason as latest-build-seq. + const status = apps.networkError ? "" : ` (HTTP ${apps.status})`; + console.error(`ensure-tf-ready: ${describeError(apps)}${status}`); process.exitCode = 2; return; } @@ -991,6 +1009,14 @@ async function main() { return; } const appId = apps.parsed.data[0].id; + // The id is what every query below filters on, so an app record without + // one would send filter[app]=undefined and produce an HTTP 400 that reads + // as a query the client built wrong. + if (typeof appId !== "string" || appId.length === 0) { + console.error(`ensure-tf-ready: the app record for ${bundleId} has no usable id`); + process.exitCode = 2; + return; + } // ---- Phase A: discovery. Poll until the uploaded build has an ASC record. let query = `/v1/builds?filter[app]=${appId}&filter[version]=${encodeURIComponent(wantBuildVersion)}`; From 97367f89707ac23845365dee1a213060b531ca7a Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:03:09 -0500 Subject: [PATCH 21/50] CC-169: submit the version for review only when it is ready Submitting is the one call in the client that cannot be undone, so the things that have to be true first are checked here instead of being discovered from Apple's refusal: the version carries its screenshots, a build is attached to it, and its versionString matches that build's marketing version. The review submission's items cannot be set from here. The relationship allows GET_RELATED and GET_RELATIONSHIP only, and a review submission is created with the app relationship, so App Store Connect binds the version itself once the version is submittable. That is also why the command lists the app's review submissions and tries each one: reviewSubmissions does not allow DELETE, so more than one can exist and the wrong one refuses. New test: scripts/ios-fleet/test-asc-submit.mjs drives the command against a local server and asserts that a version with no screenshots is not submitted at all, and that a ready one is submitted with submitted=true and platform IOS. --- .github/workflows/ios-ship-contract.yml | 1 + scripts/ios-fleet/asc-api.mjs | 137 ++++++++++++++++++++++- scripts/ios-fleet/test-asc-submit.mjs | 141 ++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 scripts/ios-fleet/test-asc-submit.mjs diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index 3e8f3bf7..174811f0 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -47,6 +47,7 @@ jobs: node --test scripts/ios-fleet/test-asc-credentials.mjs node --test scripts/ios-fleet/test-asc-client.mjs node --test scripts/ios-fleet/test-asc-upload.mjs + node --test scripts/ios-fleet/test-asc-submit.mjs python3 scripts/ios-fleet/test_pick_simulator.py python3 scripts/ios-fleet/test_screenshot_ink.py bash scripts/ios-fleet/test-capture-script.sh diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 8d50338e..b4703770 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -421,7 +421,8 @@ async function main() { console.error("Usage: node asc-api.mjs [JSON_BODY]"); console.error(" node asc-api.mjs ensure-tf-ready [marketingVersion]"); console.error(" node asc-api.mjs latest-build-seq "); - console.error(" node asc-api.mjs upload-screenshots "); + console.error(" node asc-api.mjs upload-screenshots "); + console.error(" node asc-api.mjs submit-review [reviewSubmissionId]"); process.exitCode = 1; return; } @@ -588,6 +589,140 @@ async function main() { } } + // ------------------------------------------------------------------------- + // Submit an app store version for review + // ------------------------------------------------------------------------- + // Usage: node asc-api.mjs submit-review [reviewSubmissionId] + // + // Submitting is the one call that cannot be undone, so the things that + // have to be true first are checked here rather than discovered from + // Apple's refusal: + // + // 1. the version's screenshots are uploaded (a version with no + // screenshots is not submittable, and the review submission's items + // stay empty while that is true) + // 2. a build is attached to the version + // 3. the version's versionString matches that build's marketing version + // + // The review submission's items cannot be set: the relationship allows + // GET_RELATED and GET_RELATIONSHIP only, and a review submission is + // created with the app relationship, so the version is bound by App Store + // Connect itself when the version becomes submittable. + if (method === "submit-review") { + const versionId = path; + const submissionId = body; + if (!versionId) { + console.error("Usage: node asc-api.mjs submit-review [reviewSubmissionId]"); + process.exitCode = 2; + return; + } + + // 1. The screenshots. + const localizations = await api("GET", `/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations`); + if (!localizations.ok) { + console.error(`submit-review: ${describeError(localizations)}`); + process.exitCode = 2; + return; + } + const locs = requireArray(localizations.parsed?.data, "version localization list"); + let shots = 0; + for (const loc of locs) { + if (!isPlainObject(loc) || typeof loc.id !== "string") continue; + const sets = await api("GET", `/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`); + if (!sets.ok) continue; + for (const set of requireArray(sets.parsed?.data, "screenshot set list")) { + shots += (set?.attributes?.screenshotCount) || 0; + } + } + console.log(`submit-review: the version carries ${shots} screenshot(s)`); + if (shots < 6) { + console.error("submit-review: not submitting, the version needs at least six screenshots"); + process.exitCode = 2; + return; + } + + // 2. The build. + const attached = await api("GET", `/v1/appStoreVersions/${versionId}/build`); + const build = attached.ok ? attached.parsed?.data : null; + if (!isPlainObject(build) || typeof build.id !== "string") { + console.error("submit-review: not submitting, no build is attached to the version"); + process.exitCode = 2; + return; + } + + // 3. The version string. + const version = await api("GET", `/v1/appStoreVersions/${versionId}`); + const versionString = version.ok ? version.parsed?.data?.attributes?.versionString : null; + const appId = version.ok + ? (version.parsed?.data?.relationships?.app?.data?.id) + : null; + const buildVersion = build.attributes?.version; + console.log(`submit-review: version ${versionString} | build ${buildVersion} (${build.id})`); + if (typeof versionString !== "string" || versionString.length === 0) { + console.error("submit-review: not submitting, the version has no versionString"); + process.exitCode = 2; + return; + } + + // The review submission. When none is named, the app's own are listed + // and each is tried: reviewSubmissions does not allow DELETE, so more + // than one can exist and the wrong one refuses. + let candidates = []; + if (submissionId) { + candidates = [submissionId]; + } else { + if (typeof appId !== "string" || appId.length === 0) { + console.error("submit-review: not submitting, the version names no app"); + process.exitCode = 2; + return; + } + const listed = await api("GET", `/v1/apps/${appId}/reviewSubmissions`); + if (!listed.ok) { + console.error(`submit-review: ${describeError(listed)}`); + process.exitCode = 2; + return; + } + candidates = requireArray(listed.parsed?.data, "review submission list").map((s) => s?.id).filter(Boolean); + } + if (candidates.length === 0) { + console.error("submit-review: not submitting, the app has no review submission"); + process.exitCode = 2; + return; + } + + let submitted = null; + for (const candidate of candidates) { + const result = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ + data: { + type: "reviewSubmissions", + id: candidate, + attributes: { submitted: true, platform: "IOS" } + } + })); + if (!result.ok) { + console.error(`submit-review: ${candidate} refused: ${describeError(result)}`); + continue; + } + submitted = candidate; + break; + } + if (!submitted) { + console.error("submit-review: not submitting, every review submission refused"); + process.exitCode = 2; + return; + } + const after = await api("GET", `/v1/reviewSubmissions/${submitted}`); + console.log(JSON.stringify({ + ok: true, + reviewSubmission: submitted, + state: after.ok ? after.parsed?.data?.attributes?.state : null, + submittedDate: after.ok ? after.parsed?.data?.attributes?.submittedDate : null, + versionString, + build: build.id + })); + return; + } + // ------------------------------------------------------------------------- // App Store screenshot upload // ------------------------------------------------------------------------- diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs new file mode 100644 index 00000000..fcc36e73 --- /dev/null +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -0,0 +1,141 @@ +// The submit-review command refuses to submit a version that is not ready. +// +// Submitting is the one call in this client that cannot be undone, so the +// things that have to be true first are checked rather than discovered from +// Apple's refusal. These tests drive the real command against a local +// server: no network, no fleet-secrets, no real secret. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const script = join(here, "asc-api.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-submit-test-")); + +const keyPath = join(root, "synthetic.p8"); +writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" })); + +const VERSION_ID = "version-1"; +const APP_ID = "app-1"; +const SUBMISSION_ID = "sub-1"; + +// A server that answers the endpoints the command walks, with whatever +// number of screenshots the test wants to claim the version carries. +function serve(screenshots) { + const patched = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { raw += chunk; }); + req.on("end", () => { + const json = (status, payload) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }; + const url = req.url.split("?")[0]; + + if (req.method === "PATCH" && url.startsWith("/v1/reviewSubmissions/")) { + patched.push({ url, body: JSON.parse(raw || "{}") }); + return json(200, { + data: { + type: "reviewSubmissions", + id: SUBMISSION_ID, + attributes: { state: "WAITING_FOR_REVIEW", submittedDate: "2026-10-20T00:00:00Z" } + } + }); + } + if (url === `/v1/appStoreVersions/${VERSION_ID}/appStoreVersionLocalizations`) { + return json(200, { data: [{ type: "appStoreVersionLocalizations", id: "loc-1" }] }); + } + if (url === "/v1/appStoreVersionLocalizations/loc-1/appScreenshotSets") { + return json(200, { data: [ + { id: "set-1", attributes: { screenshotDisplayType: "APP_IPHONE_67", screenshotCount: 3 } }, + { id: "set-2", attributes: { screenshotDisplayType: "APP_IPAD_PRO_129", screenshotCount: screenshots - 3 } } + ] }); + } + if (url === `/v1/appStoreVersions/${VERSION_ID}/build`) { + return json(200, { data: { type: "builds", id: "build-1", attributes: { version: "241" } } }); + } + if (url === `/v1/appStoreVersions/${VERSION_ID}`) { + return json(200, { data: { + type: "appStoreVersions", + id: VERSION_ID, + attributes: { versionString: "1.0.241", appStoreState: "PREPARE_FOR_SUBMISSION" }, + relationships: { app: { data: { type: "apps", id: APP_ID } } } + } }); + } + if (url === `/v1/apps/${APP_ID}/reviewSubmissions`) { + return json(200, { data: [{ + id: SUBMISSION_ID, + type: "reviewSubmissions", + attributes: { platform: "IOS", state: "READY_FOR_REVIEW", submittedDate: null } + }] }); + } + return json(404, { errors: [{ title: "not found" }] }); + }); + }); + return { server, patched }; +} + +function submit(base) { + return new Promise((resolve) => { + const child = spawn("node", [script, "submit-review", VERSION_ID], { + env: { + ...process.env, + ASC_API_BASE: base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_PATH: keyPath + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +async function withServer(screenshots, fn) { + const { server, patched } = serve(screenshots); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + return await fn(base, patched); + } finally { + server.close(); + } +} + +test("a version with no screenshots is not submitted", async () => { + await withServer(0, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /needs at least six screenshots/.test(stderr), + `the refusal did not say what was missing:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the version was not ready"); + }); +}); + +test("a version that is ready is submitted", async () => { + await withServer(6, async (base, patched) => { + const { code, stdout, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + const report = JSON.parse(stdout.trim().split("\n").pop()); + assert.equal(report.ok, true, `the submission did not report success:\n${stdout}`); + assert.equal(report.reviewSubmission, SUBMISSION_ID); + assert.equal(patched.length, 1, "the submission was not patched"); + const attributes = patched[0].body?.data?.attributes; + assert.equal(attributes?.submitted, true, "the submission was not marked submitted"); + assert.equal(attributes?.platform, "IOS", "the platform was not set"); + }); +}); From 10b330cd7cddbc15c6772b1ec0c4fc1d5f67bc81 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:08:48 +0000 Subject: [PATCH 22/50] autofix: make the masked ASC key preview identify the key it previews The 401/403 diagnostic previews the PEM envelope, which is the same for every .p8, so the preview named none of the keys the operator may hold. It also sliced the trailing newline into the preview, which split the diagnostic into two lines; ship-testflight.sh forwards only the lines that start with "latest-build-seq:", so the half carrying the HTTP status was dropped from the fleet log. The preview now comes from the key material, trimmed, so it discriminates between keys and stays on one line. The test now compares two keys' previews instead of recomputing the same formula from the same file, which could never catch this, and asserts the forwarded line still carries the status. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 15 +++++++- scripts/ios-fleet/test-asc-client.mjs | 53 ++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index b4703770..f11297f4 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -36,11 +36,22 @@ class NamedFailure extends Error {} // apps use (maskApiKeyPreview, buildKeyPreview). It exists because there is // otherwise no way to tell which of several keys is the one in play, and // pasting a value to find out is never the answer. +// +// The preview is taken from the key material rather than from the PEM +// envelope: every .p8 opens "-----BEGIN" and ends "-----END PRIVATE KEY-----\n", +// so previewing the envelope prints the same eight and four characters for +// every key and identifies none of them. The text is trimmed first, because +// the key as stored ends with a newline and a preview carrying one splits the +// diagnostic into two lines, of which ship-testflight.sh keeps only the first. function maskKeyPreview(value) { - if (typeof value !== "string" || value.length < 12) { + if (typeof value !== "string") { + return "(too short to preview)"; + } + const material = value.replace(/-----[^-]+-----/g, "").trim(); + if (material.length < 12) { return "(too short to preview)"; } - return `${value.slice(0, 8)}โ€ฆ${value.slice(-4)}`; + return `${material.slice(0, 8)}โ€ฆ${material.slice(-4)}`; } // The App Store Connect host. diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs index 081a82e6..271c8ea7 100644 --- a/scripts/ios-fleet/test-asc-client.mjs +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -185,13 +185,25 @@ test("a rejected key is reported as a rejected key, not as a missing app", async `a rejected key was reported as a missing app:\n${result.stderr}` ); // The fleet's masked preview, so the key in play can be identified without - // any of it being published. + // any of it being published. It is taken from the key material rather + // than from the PEM envelope: the envelope is the same for every key, so + // a preview of it names none of them. const pem = readFileSync(keyPath, "utf8"); - const expectedPreview = `${pem.slice(0, 8)}โ€ฆ${pem.slice(-4)}`; + const material = pem.replace(/-----[^-]+-----/g, "").trim(); + const expectedPreview = `${material.slice(0, 8)}โ€ฆ${material.slice(-4)}`; assert.ok( result.stderr.includes(expectedPreview), `the failure did not carry the masked key preview ${expectedPreview}:\n${result.stderr}` ); + // A preview with a newline in it splits the diagnostic into two lines, and + // ship-testflight.sh forwards only the lines starting with "latest-build-seq:", + // so the second half โ€” which is where the HTTP status is โ€” is lost. + const diagnostic = result.stderr.split("\n").find((line) => line.startsWith("latest-build-seq:")); + assert.ok(diagnostic, `no forwarded diagnostic line:\n${result.stderr}`); + assert.ok( + /HTTP 401/.test(diagnostic), + `the HTTP status was split off the forwarded line:\n${result.stderr}` + ); // The key id is public: it travels in every request to App Store // Connect, and it is the part that says which credential was refused. assert.ok( @@ -200,6 +212,43 @@ test("a rejected key is reported as a rejected key, not as a missing app", async ); }); +test("two different keys do not get the same masked preview", async () => { + // The preview exists to say which credential was rejected. Recomputing it + // from the same file in the test above cannot catch a preview that is + // identical for every key, so the two keys have to be compared with each + // other. + const otherKeyPath = join(root, "other-synthetic.p8"); + writeFileSync(otherKeyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" })); + + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const previews = []; + try { + for (const path of [keyPath, otherKeyPath]) { + const result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { + ASC_API_BASE: base, + ASC_KEY_PATH: path + }); + const diagnostic = result.stderr.split("\n").find((line) => line.startsWith("latest-build-seq:")); + const preview = diagnostic && diagnostic.match(/key in play (\S+)/); + assert.ok(preview, `no key preview in the diagnostic:\n${result.stderr}`); + previews.push(preview[1]); + } + } finally { + server.close(); + } + assert.notEqual( + previews[0], + previews[1], + "two different keys produced the same masked preview, so it identifies neither" + ); +}); + test("a large payload is not truncated on the way out", async () => { // The failure body is deliberately bigger than a pipe buffer, so a write // that is still queued when the process ends is visibly cut short. This From aa2a08045f0771444966b0901b415cff8920c295 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:16:22 -0500 Subject: [PATCH 23/50] CC-169: refuse a version whose version string is not the build's App Store Connect pairs a build with a version by their marketing version, so a version whose versionString is still the one from the previous build is refused. That is a cheap mistake to make and an expensive one to find out about from Apple, so submit-review checks it and says which value is right. The build's marketing version comes from its preReleaseVersion, which is not in the build's own attributes, so the build is read with that included. A third test covers it: a version that says one thing and a build that says another is not submitted, and the refusal names both. --- scripts/ios-fleet/asc-api.mjs | 25 ++++++++++++++++++++- scripts/ios-fleet/test-asc-submit.mjs | 32 ++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index f11297f4..dcc97f9a 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -661,7 +661,12 @@ async function main() { return; } - // 3. The version string. + // 3. The version string, and the build's own marketing version. + // + // App Store Connect pairs a build with a version by their marketing + // version, so a version whose versionString is still the one from the + // previous build is refused. That is a cheap mistake to make and an + // expensive one to find out about from Apple, so it is checked here. const version = await api("GET", `/v1/appStoreVersions/${versionId}`); const versionString = version.ok ? version.parsed?.data?.attributes?.versionString : null; const appId = version.ok @@ -675,6 +680,24 @@ async function main() { return; } + const withPreRelease = await api("GET", `/v1/builds/${build.id}?include=preReleaseVersion`); + const included = withPreRelease.ok ? requireArray(withPreRelease.parsed?.included, "build include") : []; + const preReleaseId = withPreRelease.ok + ? (withPreRelease.parsed?.data?.relationships?.preReleaseVersion?.data?.id) + : null; + const preRelease = included.find((item) => isPlainObject(item) && item.id === preReleaseId); + const marketing = preRelease && isPlainObject(preRelease.attributes) + ? preRelease.attributes.versionString + : null; + if (typeof marketing === "string" && marketing.length > 0 && marketing !== versionString) { + console.error( + `submit-review: not submitting, the version says ${versionString} and the build says ${marketing}. ` + + "Set the version's versionString to the build's marketing version first." + ); + process.exitCode = 2; + return; + } + // The review submission. When none is named, the app's own are listed // and each is tried: reviewSubmissions does not allow DELETE, so more // than one can exist and the wrong one refuses. diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index fcc36e73..46aa267a 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -28,7 +28,7 @@ const SUBMISSION_ID = "sub-1"; // A server that answers the endpoints the command walks, with whatever // number of screenshots the test wants to claim the version carries. -function serve(screenshots) { +function serve(screenshots, marketing) { const patched = []; const server = createServer((req, res) => { let raw = ""; @@ -62,6 +62,17 @@ function serve(screenshots) { if (url === `/v1/appStoreVersions/${VERSION_ID}/build`) { return json(200, { data: { type: "builds", id: "build-1", attributes: { version: "241" } } }); } + if (url === `/v1/builds/build-1`) { + return json(200, { + data: { + type: "builds", + id: "build-1", + attributes: { version: "241" }, + relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } + }, + included: [{ type: "preReleaseVersions", id: "pre-1", attributes: { versionString: marketing } }] + }); + } if (url === `/v1/appStoreVersions/${VERSION_ID}`) { return json(200, { data: { type: "appStoreVersions", @@ -103,8 +114,8 @@ function submit(base) { }); } -async function withServer(screenshots, fn) { - const { server, patched } = serve(screenshots); +async function withServer(screenshots, fn, marketing = "1.0.241") { + const { server, patched } = serve(screenshots, marketing); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const base = `http://127.0.0.1:${server.address().port}`; try { @@ -126,6 +137,21 @@ test("a version with no screenshots is not submitted", async () => { }); }); +test("a version whose version string does not match the build is not submitted", async () => { + // App Store Connect pairs a build with a version by their marketing + // version, so a version still carrying the previous build's string is + // refused. The command has to say which value is right. + await withServer(6, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /the version says 1\.0\.241 and the build says 1\.0\.240/.test(stderr), + `the refusal did not name both versions:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the version did not match"); + }, "1.0.240"); +}); + test("a version that is ready is submitted", async () => { await withServer(6, async (base, patched) => { const { code, stdout, stderr } = await submit(base); From 902565717199c68542bf001c0a505cb2b83b2325 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:30:34 -0500 Subject: [PATCH 24/50] CC-169: make the submit-review gate honest about what it checked Seven findings on the command, all addressed here. The screenshot check is per device family, not a total. App Store Connect wants a screenshot for every family the app runs on, and a pooled count lets three iPhone images and an empty iPad set through as six. The counts are kept per display type and reported per type, and a family with no image is named. A screenshot set that could not be read stops the submission. A failed read is not an absent set, and treating it as absent would let an irreversible submission go out on a read that never happened. A review submission is only submitted if it is bound to this version. A submission can be bound to another one, and submitting that would send the wrong build to review. The platform is an argument, so the same command works for the Mac app, and a platform that is not IOS or MAC_OS is refused by name. The reads that feed the checks are checked for success before their contents are used, so a failed read is reported as a failed read. Only the report goes to stdout. A wrapper that parses stdout as JSON cannot be broken by a progress line, so the progress moved to stderr. The report's own fields are shape-checked before they are printed, so a response that does not look like a review submission is a failure rather than a report of nothing. The test now covers all of it: an empty iPad set beside three iPhone images, a set that cannot be read, a submission bound to another version, a version string that does not match the build, the platform argument, and that stdout carries the report and nothing else. --- scripts/ios-fleet/asc-api.mjs | 143 ++++++++++++++++++++++---- scripts/ios-fleet/test-asc-submit.mjs | 136 +++++++++++++++++++----- 2 files changed, 233 insertions(+), 46 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index dcc97f9a..f8251cac 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -54,6 +54,24 @@ function maskKeyPreview(value) { return `${material.slice(0, 8)}โ€ฆ${material.slice(-4)}`; } +// The device families a version has to carry a screenshot in. +// +// App Store Connect wants a screenshot for every device family the app runs +// on, and the iOS app runs on iPhone and iPad. The requirement is counted +// per family rather than per display type because either iPhone size and +// either iPad size satisfies it, and a total across both is not enough: three +// iPhone images and an empty iPad set would pass a total of six and still be +// refused. +const REQUIRED_SCREENSHOT_FAMILIES = ["iphone", "ipad"]; + +// Which device family a screenshot display type belongs to. +const DISPLAY_TYPE_FAMILY = { + APP_IPHONE_67: "iphone", + APP_IPHONE_65: "iphone", + APP_IPAD_PRO_129: "ipad", + APP_IPAD_PRO_3GEN_129: "ipad" +}; + // The App Store Connect host. const PRODUCTION_API_BASE = "https://api.appstoreconnect.apple.com"; @@ -622,13 +640,26 @@ async function main() { if (method === "submit-review") { const versionId = path; const submissionId = body; + const platform = arg4 || "IOS"; if (!versionId) { - console.error("Usage: node asc-api.mjs submit-review [reviewSubmissionId]"); + console.error("Usage: node asc-api.mjs submit-review [reviewSubmissionId] [platform]"); + process.exitCode = 2; + return; + } + if (!/^(IOS|MAC_OS)$/.test(platform)) { + console.error(`submit-review: ${platform} is not a review platform; use IOS or MAC_OS`); process.exitCode = 2; return; } // 1. The screenshots. + // + // The counts are kept per display type, not pooled: App Store Connect + // wants a screenshot for every device family the app runs on, so three + // iPhone images and an empty iPad set would pass a total of six and + // still be refused. A set that could not be read is a failure, not an + // absent set: treating it as absent would let an irreversible + // submission go out on a read that never happened. const localizations = await api("GET", `/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations`); if (!localizations.ok) { console.error(`submit-review: ${describeError(localizations)}`); @@ -636,25 +667,55 @@ async function main() { return; } const locs = requireArray(localizations.parsed?.data, "version localization list"); - let shots = 0; + const byDisplayType = new Map(); for (const loc of locs) { if (!isPlainObject(loc) || typeof loc.id !== "string") continue; const sets = await api("GET", `/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`); - if (!sets.ok) continue; + if (!sets.ok) { + console.error(`submit-review: ${describeError(sets)}`); + process.exitCode = 2; + return; + } for (const set of requireArray(sets.parsed?.data, "screenshot set list")) { - shots += (set?.attributes?.screenshotCount) || 0; + const displayType = isPlainObject(set) ? set.attributes?.screenshotDisplayType : null; + const count = isPlainObject(set) ? set.attributes?.screenshotCount : null; + if (typeof displayType !== "string" || typeof count !== "number") continue; + byDisplayType.set(displayType, (byDisplayType.get(displayType) || 0) + count); } } - console.log(`submit-review: the version carries ${shots} screenshot(s)`); - if (shots < 6) { - console.error("submit-review: not submitting, the version needs at least six screenshots"); + const breakdown = [...byDisplayType.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([type, count]) => `${type} ${count}`) + .join(", "); + console.error(`submit-review: screenshots by display type: ${breakdown || "none"}`); + // The requirement is counted per device family, and a set that exists + // but holds nothing does not count: an empty iPad set is the case a + // pooled total lets through. + const familyCounts = new Map(); + for (const [type, count] of byDisplayType) { + const family = DISPLAY_TYPE_FAMILY[type]; + if (!family) continue; + familyCounts.set(family, (familyCounts.get(family) || 0) + count); + } + const missing = REQUIRED_SCREENSHOT_FAMILIES.filter( + (family) => (familyCounts.get(family) || 0) < 1 + ); + if (missing.length > 0) { + console.error( + `submit-review: not submitting, the version has no screenshot for ${missing.join(" and ")}` + ); process.exitCode = 2; return; } // 2. The build. const attached = await api("GET", `/v1/appStoreVersions/${versionId}/build`); - const build = attached.ok ? attached.parsed?.data : null; + if (!attached.ok) { + console.error(`submit-review: ${describeError(attached)}`); + process.exitCode = 2; + return; + } + const build = attached.parsed?.data; if (!isPlainObject(build) || typeof build.id !== "string") { console.error("submit-review: not submitting, no build is attached to the version"); process.exitCode = 2; @@ -668,12 +729,15 @@ async function main() { // previous build is refused. That is a cheap mistake to make and an // expensive one to find out about from Apple, so it is checked here. const version = await api("GET", `/v1/appStoreVersions/${versionId}`); - const versionString = version.ok ? version.parsed?.data?.attributes?.versionString : null; - const appId = version.ok - ? (version.parsed?.data?.relationships?.app?.data?.id) - : null; + if (!version.ok) { + console.error(`submit-review: ${describeError(version)}`); + process.exitCode = 2; + return; + } + const versionString = version.parsed?.data?.attributes?.versionString; + const appId = version.parsed?.data?.relationships?.app?.data?.id; const buildVersion = build.attributes?.version; - console.log(`submit-review: version ${versionString} | build ${buildVersion} (${build.id})`); + console.error(`submit-review: version ${versionString} | build ${buildVersion} (${build.id})`); if (typeof versionString !== "string" || versionString.length === 0) { console.error("submit-review: not submitting, the version has no versionString"); process.exitCode = 2; @@ -698,9 +762,10 @@ async function main() { return; } - // The review submission. When none is named, the app's own are listed - // and each is tried: reviewSubmissions does not allow DELETE, so more - // than one can exist and the wrong one refuses. + // The review submission. When none is named, the app's own are listed. + // Each candidate is checked to be bound to THIS version before it is + // submitted: a submission can be bound to another version, and + // submitting that one would send the wrong build to review. let candidates = []; if (submissionId) { candidates = [submissionId]; @@ -716,7 +781,9 @@ async function main() { process.exitCode = 2; return; } - candidates = requireArray(listed.parsed?.data, "review submission list").map((s) => s?.id).filter(Boolean); + candidates = requireArray(listed.parsed?.data, "review submission list") + .map((s) => (isPlainObject(s) && typeof s.id === "string" ? s.id : null)) + .filter(Boolean); } if (candidates.length === 0) { console.error("submit-review: not submitting, the app has no review submission"); @@ -726,11 +793,22 @@ async function main() { let submitted = null; for (const candidate of candidates) { + const items = await api("GET", `/v1/reviewSubmissions/${candidate}/items`); + if (!items.ok) { + console.error(`submit-review: ${candidate}: ${describeError(items)}`); + continue; + } + const bound = requireArray(items.parsed?.data, "review submission item list") + .some((item) => isPlainObject(item) && item.id === versionId); + if (!bound) { + console.error(`submit-review: ${candidate} is not bound to ${versionId}; skipping it`); + continue; + } const result = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ data: { type: "reviewSubmissions", id: candidate, - attributes: { submitted: true, platform: "IOS" } + attributes: { submitted: true, platform } } })); if (!result.ok) { @@ -741,18 +819,39 @@ async function main() { break; } if (!submitted) { - console.error("submit-review: not submitting, every review submission refused"); + console.error("submit-review: not submitting, no review submission is bound to this version"); process.exitCode = 2; return; } const after = await api("GET", `/v1/reviewSubmissions/${submitted}`); + if (!after.ok) { + console.error(`submit-review: ${describeError(after)}`); + process.exitCode = 2; + return; + } + const attributes = after.parsed?.data?.attributes; + const state = isPlainObject(attributes) ? attributes.state : null; + const submittedDate = isPlainObject(attributes) ? attributes.submittedDate : null; + if (state !== null && typeof state !== "string") { + console.error("submit-review: the review submission came back with an unreadable state"); + process.exitCode = 2; + return; + } + if (submittedDate !== null && typeof submittedDate !== "string") { + console.error("submit-review: the review submission came back with an unreadable submittedDate"); + process.exitCode = 2; + return; + } + // Only the report goes to stdout, so a wrapper that parses it as JSON + // cannot be broken by a progress line. console.log(JSON.stringify({ ok: true, reviewSubmission: submitted, - state: after.ok ? after.parsed?.data?.attributes?.state : null, - submittedDate: after.ok ? after.parsed?.data?.attributes?.submittedDate : null, + state, + submittedDate, versionString, - build: build.id + build: build.id, + platform })); return; } diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 46aa267a..74426ee6 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -26,9 +26,14 @@ const VERSION_ID = "version-1"; const APP_ID = "app-1"; const SUBMISSION_ID = "sub-1"; -// A server that answers the endpoints the command walks, with whatever -// number of screenshots the test wants to claim the version carries. -function serve(screenshots, marketing) { +// A server that answers the endpoints the command walks. +// +// `options` says what the server should report: +// iphoneScreens / ipadScreens: the count in each display type's set +// setsFail: the screenshot-set read answers 401 +// boundTo: the version the review submission is bound to +// platform: the platform the submission answers with +function serve(options) { const patched = []; const server = createServer((req, res) => { let raw = ""; @@ -54,30 +59,39 @@ function serve(screenshots, marketing) { return json(200, { data: [{ type: "appStoreVersionLocalizations", id: "loc-1" }] }); } if (url === "/v1/appStoreVersionLocalizations/loc-1/appScreenshotSets") { + if (options.setsFail) { + return json(401, { errors: [{ title: "unauthorized" }] }); + } return json(200, { data: [ - { id: "set-1", attributes: { screenshotDisplayType: "APP_IPHONE_67", screenshotCount: 3 } }, - { id: "set-2", attributes: { screenshotDisplayType: "APP_IPAD_PRO_129", screenshotCount: screenshots - 3 } } + { + id: "set-1", + attributes: { screenshotDisplayType: "APP_IPHONE_67", screenshotCount: options.iphoneScreens } + }, + { + id: "set-2", + attributes: { screenshotDisplayType: "APP_IPAD_PRO_129", screenshotCount: options.ipadScreens } + } ] }); } if (url === `/v1/appStoreVersions/${VERSION_ID}/build`) { - return json(200, { data: { type: "builds", id: "build-1", attributes: { version: "241" } } }); + return json(200, { data: { type: "builds", id: "build-1", attributes: { version: "243" } } }); } if (url === `/v1/builds/build-1`) { return json(200, { data: { type: "builds", id: "build-1", - attributes: { version: "241" }, + attributes: { version: "243" }, relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } }, - included: [{ type: "preReleaseVersions", id: "pre-1", attributes: { versionString: marketing } }] + included: [{ type: "preReleaseVersions", id: "pre-1", attributes: { versionString: "1.0.243" } }] }); } if (url === `/v1/appStoreVersions/${VERSION_ID}`) { return json(200, { data: { type: "appStoreVersions", id: VERSION_ID, - attributes: { versionString: "1.0.241", appStoreState: "PREPARE_FOR_SUBMISSION" }, + attributes: { versionString: options.versionString || "1.0.243", appStoreState: "PREPARE_FOR_SUBMISSION" }, relationships: { app: { data: { type: "apps", id: APP_ID } } } } }); } @@ -88,15 +102,29 @@ function serve(screenshots, marketing) { attributes: { platform: "IOS", state: "READY_FOR_REVIEW", submittedDate: null } }] }); } + if (url === `/v1/reviewSubmissions/${SUBMISSION_ID}/items`) { + return json(200, { data: [{ type: "appStoreVersions", id: options.boundTo }] }); + } + if (req.method === "GET" && url === `/v1/reviewSubmissions/${SUBMISSION_ID}`) { + // The read back after the submit, so the report can say what the + // submission's state actually is. + return json(200, { + data: { + type: "reviewSubmissions", + id: SUBMISSION_ID, + attributes: { state: "WAITING_FOR_REVIEW", submittedDate: "2026-10-20T00:00:00Z" } + } + }); + } return json(404, { errors: [{ title: "not found" }] }); }); }); return { server, patched }; } -function submit(base) { +function submit(base, args = []) { return new Promise((resolve) => { - const child = spawn("node", [script, "submit-review", VERSION_ID], { + const child = spawn("node", [script, "submit-review", VERSION_ID, ...args], { env: { ...process.env, ASC_API_BASE: base, @@ -114,8 +142,8 @@ function submit(base) { }); } -async function withServer(screenshots, fn, marketing = "1.0.241") { - const { server, patched } = serve(screenshots, marketing); +async function withServer(options, fn) { + const { server, patched } = serve(options); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const base = `http://127.0.0.1:${server.address().port}`; try { @@ -125,43 +153,103 @@ async function withServer(screenshots, fn, marketing = "1.0.241") { } } +const READY = { iphoneScreens: 3, ipadScreens: 3, boundTo: VERSION_ID }; + test("a version with no screenshots is not submitted", async () => { - await withServer(0, async (base, patched) => { + await withServer({ ...READY, iphoneScreens: 0, ipadScreens: 0 }, async (base, patched) => { const { code, stderr } = await submit(base); assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); assert.ok( - /needs at least six screenshots/.test(stderr), + /no screenshot for iphone and ipad/.test(stderr), `the refusal did not say what was missing:\n${stderr}` ); assert.equal(patched.length, 0, "the submission was patched even though the version was not ready"); }); }); +test("an empty iPad set does not pass on the iPhone images alone", async () => { + // The counts are pooled across display types, so three iPhone images and + // an empty iPad set would pass a total of six and still be refused by + // App Store Connect. + await withServer({ ...READY, ipadScreens: 0 }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /no screenshot for ipad/.test(stderr), + `the refusal did not name the missing family:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched without an iPad screenshot"); + }); +}); + +test("a screenshot set that could not be read stops the submission", async () => { + // A read that failed is not an absent set: treating it as absent would + // let an irreversible submission go out on a read that never happened. + await withServer({ ...READY, setsFail: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /rejected the key/.test(stderr), + `the failed read was not reported as one:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched after a failed read"); + }); +}); + +test("a review submission bound to another version is not submitted", async () => { + await withServer({ ...READY, boundTo: "some-other-version" }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /is not bound to version-1/.test(stderr), + `the refusal did not say the submission was bound elsewhere:\n${stderr}` + ); + assert.equal(patched.length, 0, "the wrong version's submission was patched"); + }); +}); + test("a version whose version string does not match the build is not submitted", async () => { - // App Store Connect pairs a build with a version by their marketing - // version, so a version still carrying the previous build's string is - // refused. The command has to say which value is right. - await withServer(6, async (base, patched) => { + // The version still carries the previous build's version string, so App + // Store Connect would refuse to pair it with the new build. + await withServer({ ...READY, versionString: "1.0.240" }, async (base, patched) => { const { code, stderr } = await submit(base); assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); assert.ok( - /the version says 1\.0\.241 and the build says 1\.0\.240/.test(stderr), + /the version says 1\.0\.240 and the build says 1\.0\.243/.test(stderr), `the refusal did not name both versions:\n${stderr}` ); assert.equal(patched.length, 0, "the submission was patched even though the version did not match"); - }, "1.0.240"); + }); }); -test("a version that is ready is submitted", async () => { - await withServer(6, async (base, patched) => { +test("a ready version is submitted, and only the report is on stdout", async () => { + await withServer(READY, async (base, patched) => { const { code, stdout, stderr } = await submit(base); assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); - const report = JSON.parse(stdout.trim().split("\n").pop()); + // A wrapper parses stdout as JSON, so the report has to be the only + // thing there. + const report = JSON.parse(stdout.trim()); assert.equal(report.ok, true, `the submission did not report success:\n${stdout}`); assert.equal(report.reviewSubmission, SUBMISSION_ID); + assert.equal(report.platform, "IOS"); assert.equal(patched.length, 1, "the submission was not patched"); const attributes = patched[0].body?.data?.attributes; assert.equal(attributes?.submitted, true, "the submission was not marked submitted"); assert.equal(attributes?.platform, "IOS", "the platform was not set"); }); }); + +test("the platform can be chosen, and a bad one is refused", async () => { + await withServer(READY, async (base, patched) => { + const { code, stdout } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 0, "the macOS platform was not accepted"); + const report = JSON.parse(stdout.trim()); + assert.equal(report.platform, "MAC_OS"); + assert.equal(patched[0].body?.data?.attributes?.platform, "MAC_OS"); + }); + await withServer(READY, async (base) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID, "WINDOWS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok(/not a review platform/.test(stderr), `the refusal did not say what was wrong:\n${stderr}`); + }); +}); From da841d9ec3048f9a9694d00cde74b60704faa020 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:45:50 -0500 Subject: [PATCH 25/50] CC-169: correct the comment that contradicted the masked preview The comment above describeError still said a rejected key is named and never previewed, which was true when it was written and stopped being true when the fleet's masked preview went in. It now says what the code does, and why the preview is taken from the key material rather than from the envelope. The shape guards also name the command that is running rather than assuming it is the upload, because submit-review uses them too. --- scripts/ios-fleet/asc-api.mjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index f8251cac..6329385d 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -477,9 +477,14 @@ async function main() { // The raw body of an App Store Connect error is not printed: it is // whatever the service chose to say, and it may echo a header or a token. // Only the titles and details of the JSON API errors are used, and a run of - // token-like characters in one of those is redacted. A rejected key is - // named, never previewed: a masked preview of a private key is still a - // piece of the key. + // token-like characters in one of those is redacted. + // + // A rejected key is reported with the fleet's masked preview of the key in + // play and the key id beside it, so the operator can tell which credential + // was refused without any of the key being published. The preview is taken + // from the key material rather than from the envelope, because every key + // file opens and ends the same way and a preview of the envelope would + // identify none of them. function describeError(res) { if (res.networkError) { // A timeout and a transport failure have different causes, and the @@ -531,7 +536,7 @@ async function main() { function requireObject(value, what) { if (!isPlainObject(value)) { process.exitCode = 2; - throw new NamedFailure(`upload-screenshots: invalid ${what} response`); + throw new NamedFailure(`${method}: invalid ${what} response`); } return value; } @@ -539,7 +544,7 @@ async function main() { function requireArray(value, what) { if (!Array.isArray(value)) { process.exitCode = 2; - throw new NamedFailure(`upload-screenshots: invalid ${what} response`); + throw new NamedFailure(`${method}: invalid ${what} response`); } return value; } From 93dec10a44e99c34fcf914a358ff18b05ac78d58 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:06:44 +0000 Subject: [PATCH 26/50] autofix: answer the four open submit-review findings The screenshot gate demanded the iOS device families for every submission, so a MAC_OS submission could never be ready: a macOS version carries desktop screenshots and no iPhone or iPad set. The required families now follow the platform the command was given, and APP_DESKTOP maps to the desktop family. A failed build read was converted into empty pre-release data, so an irreversible submission went out on a check that never ran, and a rejected key was swallowed instead of reported. The read now stops the command and says what went wrong, matching the other reads in this function. JSON:API omits `included` when a document has no included resources, so a build with no pre-release version carries none: that is no longer refused as a malformed response. An include that is missing while the build names one is still a read that could not be understood, and is refused. The submit tests spawned the command with a wholesale copy of the parent environment, handing it every credential on the machine; the child now gets PATH, HOME and the synthetic ASC_* values only. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 41 ++++++++++++--- scripts/ios-fleet/test-asc-submit.mjs | 75 +++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 6329385d..37a29903 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -64,12 +64,18 @@ function maskKeyPreview(value) { // refused. const REQUIRED_SCREENSHOT_FAMILIES = ["iphone", "ipad"]; +// The same requirement for a macOS version, which carries desktop +// screenshots and no iPhone or iPad set at all. A macOS submission asked +// for the iOS families would never be ready. +const REQUIRED_SCREENSHOT_FAMILIES_MAC = ["desktop"]; + // Which device family a screenshot display type belongs to. const DISPLAY_TYPE_FAMILY = { APP_IPHONE_67: "iphone", APP_IPHONE_65: "iphone", APP_IPAD_PRO_129: "ipad", - APP_IPAD_PRO_3GEN_129: "ipad" + APP_IPAD_PRO_3GEN_129: "ipad", + APP_DESKTOP: "desktop" }; // The App Store Connect host. @@ -702,7 +708,9 @@ async function main() { if (!family) continue; familyCounts.set(family, (familyCounts.get(family) || 0) + count); } - const missing = REQUIRED_SCREENSHOT_FAMILIES.filter( + const missing = (platform === "MAC_OS" + ? REQUIRED_SCREENSHOT_FAMILIES_MAC + : REQUIRED_SCREENSHOT_FAMILIES).filter( (family) => (familyCounts.get(family) || 0) < 1 ); if (missing.length > 0) { @@ -750,11 +758,32 @@ async function main() { } const withPreRelease = await api("GET", `/v1/builds/${build.id}?include=preReleaseVersion`); - const included = withPreRelease.ok ? requireArray(withPreRelease.parsed?.included, "build include") : []; - const preReleaseId = withPreRelease.ok - ? (withPreRelease.parsed?.data?.relationships?.preReleaseVersion?.data?.id) + if (!withPreRelease.ok) { + // A read that failed is not an absent pre-release version: treating + // it as absent would let an irreversible submission go out on a check + // that never ran, and it would swallow the one failure that matters + // most here, a rejected key. + console.error(`submit-review: ${describeError(withPreRelease)}`); + process.exitCode = 2; + return; + } + const included = withPreRelease.parsed?.included; + const preReleaseId = withPreRelease.parsed?.data?.relationships?.preReleaseVersion?.data?.id; + const hasPreRelease = typeof preReleaseId === "string" && preReleaseId.length > 0; + // A build with no pre-release version carries no include: JSON:API + // leaves the member out rather than sending an empty list, and there is + // nothing to compare in that case. An include that is missing while the + // build names one is a read that could not be understood. + if (hasPreRelease && !Array.isArray(included)) { + console.error( + `submit-review: not submitting, the build names pre-release ${preReleaseId} and the read returns no include for it` + ); + process.exitCode = 2; + return; + } + const preRelease = hasPreRelease + ? included.find((item) => isPlainObject(item) && item.id === preReleaseId) : null; - const preRelease = included.find((item) => isPlainObject(item) && item.id === preReleaseId); const marketing = preRelease && isPlainObject(preRelease.attributes) ? preRelease.attributes.versionString : null; diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 74426ee6..5d9daae9 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -30,7 +30,13 @@ const SUBMISSION_ID = "sub-1"; // // `options` says what the server should report: // iphoneScreens / ipadScreens: the count in each display type's set +// desktopScreens: the count in the macOS display type's set; the set is +// only served when this is given, so a server that does not name it is a +// version that carries no desktop screenshot at all // setsFail: the screenshot-set read answers 401 +// preReleaseFails: the build read answers 401 +// preReleaseMissing: the build read names no pre-release version, so the +// document carries no `included` member // boundTo: the version the review submission is bound to // platform: the platform the submission answers with function serve(options) { @@ -62,7 +68,7 @@ function serve(options) { if (options.setsFail) { return json(401, { errors: [{ title: "unauthorized" }] }); } - return json(200, { data: [ + const sets = [ { id: "set-1", attributes: { screenshotDisplayType: "APP_IPHONE_67", screenshotCount: options.iphoneScreens } @@ -71,12 +77,29 @@ function serve(options) { id: "set-2", attributes: { screenshotDisplayType: "APP_IPAD_PRO_129", screenshotCount: options.ipadScreens } } - ] }); + ]; + if (options.desktopScreens !== undefined) { + sets.push({ + id: "set-3", + attributes: { screenshotDisplayType: "APP_DESKTOP", screenshotCount: options.desktopScreens } + }); + } + return json(200, { data: sets }); } if (url === `/v1/appStoreVersions/${VERSION_ID}/build`) { return json(200, { data: { type: "builds", id: "build-1", attributes: { version: "243" } } }); } if (url === `/v1/builds/build-1`) { + if (options.preReleaseFails) { + return json(401, { errors: [{ title: "unauthorized" }] }); + } + if (options.preReleaseMissing) { + // A build that names no pre-release version, so the document + // carries no `included` member at all. + return json(200, { + data: { type: "builds", id: "build-1", attributes: { version: "243" } } + }); + } return json(200, { data: { type: "builds", @@ -125,8 +148,12 @@ function serve(options) { function submit(base, args = []) { return new Promise((resolve) => { const child = spawn("node", [script, "submit-review", VERSION_ID, ...args], { + // The child is given the values it needs and nothing else: a wholesale + // copy of the parent environment would hand every credential on this + // machine to the command under test. env: { - ...process.env, + PATH: process.env.PATH, + HOME: process.env.HOME, ASC_API_BASE: base, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", @@ -222,6 +249,33 @@ test("a version whose version string does not match the build is not submitted", }); }); +test("a failed build read stops the submission", async () => { + // The build read carries the version-string comparison. A read that + // failed is not a build with no pre-release version: submitting on it + // would send the build to review without the check that would have caught + // a mismatched version. + await withServer({ ...READY, preReleaseFails: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /submit-review: .*rejected the key/.test(stderr), + `the failed read was not reported as one:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched after a failed build read"); + }); +}); + +test("a build with no pre-release version is submitted", async () => { + // JSON:API leaves `included` out when the document has no included + // resources, so a build with no pre-release version must not be refused + // for the absence of a comparison it could not have made. + await withServer({ ...READY, preReleaseMissing: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(patched.length, 1, "the submission was not patched"); + }); +}); + test("a ready version is submitted, and only the report is on stdout", async () => { await withServer(READY, async (base, patched) => { const { code, stdout, stderr } = await submit(base); @@ -240,13 +294,26 @@ test("a ready version is submitted, and only the report is on stdout", async () }); test("the platform can be chosen, and a bad one is refused", async () => { - await withServer(READY, async (base, patched) => { + // A macOS version carries desktop screenshots, so it is submitted against + // a server that serves them rather than the iPhone and iPad sets. + await withServer({ ...READY, iphoneScreens: 0, ipadScreens: 0, desktopScreens: 3 }, async (base, patched) => { const { code, stdout } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); assert.equal(code, 0, "the macOS platform was not accepted"); const report = JSON.parse(stdout.trim()); assert.equal(report.platform, "MAC_OS"); assert.equal(patched[0].body?.data?.attributes?.platform, "MAC_OS"); }); + await withServer(READY, async (base, patched) => { + // The iPhone and iPad sets a macOS version does not carry are not + // enough for it, and submitting it anyway is the failure to catch. + const { code, stderr } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /no screenshot for desktop/.test(stderr), + `the refusal did not say what was missing:\n${stderr}` + ); + assert.equal(patched.length, 0, "the macOS submission was patched with no desktop screenshot"); + }); await withServer(READY, async (base) => { const { code, stderr } = await submit(base, [SUBMISSION_ID, "WINDOWS"]); assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); From 56eeaabd846806c4b87cd286a9061a185a8734c9 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:17:01 -0500 Subject: [PATCH 27/50] CC-169: let the upload side take a Mac screenshot too The submit gate now knows about the desktop family, so the upload side should as well. Apple's Mac screenshot sizes are all 16:10, and they belong to the desktop set, so the four of them are in the size map now: 1280x800, 1440x900, 2560x1600 and 2880x1800. Without them the upload refused a file it should have accepted, because the size was in no set at all, and it said so in terms of a size App Store Connect does not accept, which is a confusing way to be told that the file is a Mac screenshot. A test covers it: a Mac-sized file goes to the desktop set, and the mock server can now create a set for a display type it did not have. --- scripts/ios-fleet/asc-api.mjs | 9 +++++++-- scripts/ios-fleet/test-asc-upload.mjs | 28 +++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 37a29903..d10bd3bb 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -922,7 +922,8 @@ async function main() { // that size belongs to, so the display type cannot be mis-specified. // App Store Connect has no APP_IPHONE_69 or APP_IPAD_PRO_13: the 6.9-inch // iPhone size lives in APP_IPHONE_67 and the 13-inch iPad size in - // APP_IPAD_PRO_129. The sizes are Apple's own list, at + // APP_IPAD_PRO_129. The Mac sizes are Apple's own list, all 16:10, and + // they belong to the desktop set. The sizes are Apple's list, at // developer.apple.com/help/app-store-connect/reference/screenshot-specifications const sizeToDisplayType = { "1320x2868": "APP_IPHONE_67", @@ -930,7 +931,11 @@ async function main() { "1284x2778": "APP_IPHONE_65", "1242x2688": "APP_IPHONE_65", "2064x2752": "APP_IPAD_PRO_129", - "2048x2732": "APP_IPAD_PRO_3GEN_129" + "2048x2732": "APP_IPAD_PRO_3GEN_129", + "1280x800": "APP_DESKTOP", + "1440x900": "APP_DESKTOP", + "2560x1600": "APP_DESKTOP", + "2880x1800": "APP_DESKTOP" }; // Every file has to belong to the same screenshot set. A capture run // produces iPhone and iPad files together and they belong in different diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs index ccd0d489..71de9d31 100644 --- a/scripts/ios-fleet/test-asc-upload.mjs +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -38,6 +38,13 @@ ihdr.writeUInt32BE(1320, 16); ihdr.writeUInt32BE(2868, 20); writeFileSync(pngPath, ihdr); +// The same stub at one of Apple's Mac sizes, all of which are 16:10. +const macPath = join(root, "mac.png"); +const macIhdr = Buffer.alloc(24); +macIhdr.writeUInt32BE(1440, 16); +macIhdr.writeUInt32BE(900, 20); +writeFileSync(macPath, macIhdr); + // A string the server puts in a body that the script must never echo. const SECRET_MARKER = "leaked-token-marker-do-not-echo"; @@ -46,7 +53,7 @@ const SET_ID = "set-xyz-789"; // Run asc-api.mjs upload-screenshots against a local server and collect what // happened. `behaviour` picks which step, if any, fails. -function upload(behaviour) { +function upload(behaviour, file = pngPath) { return new Promise((resolve) => { const calls = []; // The pre-signed upload URL Apple hands out is absolute, so the port has to @@ -75,6 +82,15 @@ function upload(behaviour) { attributes: { screenshotDisplayType: "APP_IPHONE_67" } }] }); } + if (req.url === "/v1/appScreenshotSets" && req.method === "POST") { + // A set for a display type the server does not have yet. + const wanted = JSON.parse(body || "{}")?.data?.attributes?.screenshotDisplayType; + return json(201, { data: { + id: `set-${wanted}`, + type: "appScreenshotSets", + attributes: { screenshotDisplayType: wanted } + } }); + } if (req.url === "/v1/appScreenshots" && req.method === "POST") { return json(201, { data: { @@ -122,7 +138,7 @@ function upload(behaviour) { server.listen(0, "127.0.0.1", () => { apiBase = `http://127.0.0.1:${server.address().port}`; - const child = spawn("node", [script, "upload-screenshots", "loc-1", "auto", pngPath], { + const child = spawn("node", [script, "upload-screenshots", "loc-1", "auto", file], { env: { ...process.env, ASC_API_BASE: apiBase, @@ -154,6 +170,14 @@ const deletesFor = (calls) => calls // that distinguishes a cleaned-up reservation from a silent orphan. const reportsDropped = (stderr) => stderr.includes(`dropped the reservation ${SCREENSHOT_ID}`); +test("a Mac screenshot goes to the desktop set", async () => { + // Apple's Mac sizes are all 16:10, and they belong to the desktop set. + // Without them in the map the upload would refuse a file it should + // accept. + const { code, stderr } = await upload({}, macPath); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); +}); + test("a successful upload commits the reservation and leaves nothing open", async () => { const { code, stderr, calls } = await upload({ putFails: false, commitFails: false }); assert.equal(code, 0, `expected a clean exit, got ${code}: ${stderr}`); From 6a6e68caa5daf4bf23eedbb675b99c74015e3594 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:20:33 -0500 Subject: [PATCH 28/50] CC-169: refuse a submission when the build's marketing version is unreadable A build that names a pre-release version but whose marketing version cannot be read is not a build with nothing to compare: it is a comparison that did not happen, and letting the submission through would skip the one check that pairs the version with the build. That is now a refusal. Two cases are covered separately, because they are different failures: a read that returns no include for the pre-release version the build names, and an include that is there but carries no version string. Each has its own mock mode and its own test. --- scripts/ios-fleet/asc-api.mjs | 11 ++++++ scripts/ios-fleet/test-asc-submit.mjs | 54 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index d10bd3bb..bcfab4b5 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -787,6 +787,17 @@ async function main() { const marketing = preRelease && isPlainObject(preRelease.attributes) ? preRelease.attributes.versionString : null; + // A build that names a pre-release version but whose marketing version + // cannot be read is not a build with nothing to compare: it is a + // comparison that did not happen, and letting the submission through + // would skip the one check that pairs the version with the build. + if (hasPreRelease && (typeof marketing !== "string" || marketing.length === 0)) { + console.error( + `submit-review: not submitting, the build names pre-release ${preReleaseId} but its marketing version could not be read` + ); + process.exitCode = 2; + return; + } if (typeof marketing === "string" && marketing.length > 0 && marketing !== versionString) { console.error( `submit-review: not submitting, the version says ${versionString} and the build says ${marketing}. ` + diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 5d9daae9..6cf2efcb 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -100,6 +100,31 @@ function serve(options) { data: { type: "builds", id: "build-1", attributes: { version: "243" } } }); } + if (options.preReleaseWithoutVersion) { + // The include is there, but it carries no version string, so the + // comparison still cannot be made. + return json(200, { + data: { + type: "builds", + id: "build-1", + attributes: { version: "243" }, + relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } + }, + included: [{ type: "preReleaseVersions", id: "pre-1", attributes: {} }] + }); + } + if (options.preReleaseWithoutInclude) { + // The build names a pre-release version, but the read returns no + // include for it, so the marketing version cannot be read. + return json(200, { + data: { + type: "builds", + id: "build-1", + attributes: { version: "243" }, + relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } + } + }); + } return json(200, { data: { type: "builds", @@ -235,6 +260,35 @@ test("a review submission bound to another version is not submitted", async () = }); }); +test("a build whose pre-release version has no include stops the submission", async () => { + // The build names a pre-release version and the read returns no include + // for it, so the comparison that pairs the version with the build did not + // happen. Letting the submission through would skip it. + await withServer({ ...READY, preReleaseWithoutInclude: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /returns no include for it/.test(stderr), + `the refusal did not say what was unreadable:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched without a comparable build"); + }); +}); + +test("a build whose marketing version cannot be read stops the submission", async () => { + // The include is there but carries no version string, so the comparison + // is still impossible and the submission must not go out. + await withServer({ ...READY, preReleaseWithoutVersion: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /marketing version could not be read/.test(stderr), + `the refusal did not say what was unreadable:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched without a comparable build"); + }); +}); + test("a version whose version string does not match the build is not submitted", async () => { // The version still carries the previous build's version string, so App // Store Connect would refuse to pair it with the new build. From 5224ad7b9c0ec39c17ef110c4423163c4e23ce7e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:38:18 +0000 Subject: [PATCH 29/50] autofix: read the marketing version off the pre-release version The pre-release guard read `preRelease.attributes.versionString`, but the marketing version on a preReleaseVersions resource is `version`; `versionString` belongs to appStoreVersions, which is where the version's own name is read. Every App Store build names a pre-release version, so `hasPreRelease` was always true and the guard fired on every run: submit-review exited 2 and could never submit anything. The submit mock encoded the same wrong field, so the suite stayed green. Correct both. The Mac upload test only checked the exit code, and the mock echoed the requested display type back, so an incorrect size-to-display-type mapping still exited 0. Record the created set and assert it directly. Co-Authored-By: Claude Code --- scripts/ios-fleet/asc-api.mjs | 5 ++++- scripts/ios-fleet/test-asc-submit.mjs | 2 +- scripts/ios-fleet/test-asc-upload.mjs | 17 ++++++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index bcfab4b5..7b2fd06b 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -784,8 +784,11 @@ async function main() { const preRelease = hasPreRelease ? included.find((item) => isPlainObject(item) && item.id === preReleaseId) : null; + // The marketing version lives on the pre-release version as `version` + // (see the field map above); `versionString` belongs to + // appStoreVersions, which is where the version's own name is read. const marketing = preRelease && isPlainObject(preRelease.attributes) - ? preRelease.attributes.versionString + ? preRelease.attributes.version : null; // A build that names a pre-release version but whose marketing version // cannot be read is not a build with nothing to compare: it is a diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 6cf2efcb..08edb839 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -132,7 +132,7 @@ function serve(options) { attributes: { version: "243" }, relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } }, - included: [{ type: "preReleaseVersions", id: "pre-1", attributes: { versionString: "1.0.243" } }] + included: [{ type: "preReleaseVersions", id: "pre-1", attributes: { version: "1.0.243" } }] }); } if (url === `/v1/appStoreVersions/${VERSION_ID}`) { diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs index 71de9d31..a991cd9d 100644 --- a/scripts/ios-fleet/test-asc-upload.mjs +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -56,6 +56,7 @@ const SET_ID = "set-xyz-789"; function upload(behaviour, file = pngPath) { return new Promise((resolve) => { const calls = []; + const createdSets = []; // The pre-signed upload URL Apple hands out is absolute, so the port has to // be known before a reservation is written -- and the port is only known // once the server is listening. @@ -85,6 +86,10 @@ function upload(behaviour, file = pngPath) { if (req.url === "/v1/appScreenshotSets" && req.method === "POST") { // A set for a display type the server does not have yet. const wanted = JSON.parse(body || "{}")?.data?.attributes?.screenshotDisplayType; + // The response echoes `wanted`, so echoing it back cannot tell a + // correct size-to-display-type mapping from a wrong one. Record what + // was actually asked for so a test can assert on it directly. + createdSets.push(wanted); return json(201, { data: { id: `set-${wanted}`, type: "appScreenshotSets", @@ -153,7 +158,7 @@ function upload(behaviour, file = pngPath) { child.stdout.on("data", (d) => { stdout += d; }); child.stderr.on("data", (d) => { stderr += d; }); child.on("close", (code) => { - server.close(() => resolve({ code, stdout, stderr, calls })); + server.close(() => resolve({ code, stdout, stderr, calls, createdSets })); }); }); }); @@ -173,9 +178,15 @@ const reportsDropped = (stderr) => stderr.includes(`dropped the reservation ${SC test("a Mac screenshot goes to the desktop set", async () => { // Apple's Mac sizes are all 16:10, and they belong to the desktop set. // Without them in the map the upload would refuse a file it should - // accept. - const { code, stderr } = await upload({}, macPath); + // accept. The exit code alone cannot tell the desktop set from a wrong + // one, because the mock answers with whatever display type it was asked + // for, so the created set is asserted on directly. + const { code, stderr, createdSets } = await upload({}, macPath); assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.deepEqual( + createdSets, ["APP_DESKTOP"], + `the Mac size was mapped to the wrong set: ${JSON.stringify(createdSets)}` + ); }); test("a successful upload commits the reservation and leaves nothing open", async () => { From 17103ace70c2207838c960bba0cec64872da43cd Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:42:18 -0500 Subject: [PATCH 30/50] CC-169: say which field the marketing version is read from The comment above it referred to a field map that does not exist, and the fact it needed to state is worth stating properly: the live API answers a build read with included[].attributes.version holding the marketing version, and no versionString on that resource at all. versionString is the field of an appStoreVersion, which is where the version's own name is read. The test's mock now carries a decoy versionString beside the real one, so it fails if the command ever reads the wrong field again. Reading the wrong one was not a visible failure: the field was simply always absent, the comparison was silently skipped, and the version string would have gone to review without ever being checked against the build. --- scripts/ios-fleet/asc-api.mjs | 9 ++++++--- scripts/ios-fleet/test-asc-submit.mjs | 11 ++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 7b2fd06b..cffc451c 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -784,9 +784,12 @@ async function main() { const preRelease = hasPreRelease ? included.find((item) => isPlainObject(item) && item.id === preReleaseId) : null; - // The marketing version lives on the pre-release version as `version` - // (see the field map above); `versionString` belongs to - // appStoreVersions, which is where the version's own name is read. + // The marketing version lives on the pre-release version as `version`. + // That is not a guess: the live API answers + // GET /v1/builds/?include=preReleaseVersion with + // included[].attributes.version = "1.0.235" and no `versionString` on + // that resource at all. `versionString` is the field of an + // appStoreVersion, which is where the version's own name is read above. const marketing = preRelease && isPlainObject(preRelease.attributes) ? preRelease.attributes.version : null; diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 08edb839..3d702a2a 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -132,7 +132,16 @@ function serve(options) { attributes: { version: "243" }, relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } }, - included: [{ type: "preReleaseVersions", id: "pre-1", attributes: { version: "1.0.243" } }] + // The pre-release version carries `version`, and the live API + // answers with no `versionString` on that resource at all. A + // `versionString` here would be a different field, and the + // command must not read it: `versionString` belongs to an + // appStoreVersion. + included: [{ + type: "preReleaseVersions", + id: "pre-1", + attributes: { version: "1.0.243", versionString: "9.9.9", platform: "IOS" } + }] }); } if (url === `/v1/appStoreVersions/${VERSION_ID}`) { From e613fdd1dd3212e3927f34b844e398aea873fa45 Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 03:47:22 -0500 Subject: [PATCH 31/50] CC-180: pick up test rewrite + stop the "7d 7-day" widget caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PipInteractionTests.testFailedAntigravityReadingStaysPinnedWithoutShowingCachedQuota drove an impossible contract. Its sibling in PR #281, testAllUnavailableFallbackStillShowsStatusRows, says the panel still lists two status rows when nothing is readable, while the failing test said the failed row must not appear in targetRows once the pin is cleared. Adopt Jay's test-only fix from origin/minimax/pip-antigravity-pin-regression (CC-180, ce170ff): the shipped behaviour matches CC-178 โ€” the panel claimed 54 percent while Glance said unavailable โ€” so the new invariant is "every row listed while nothing is readable carries an unavailable status and zero cached meters." WidgetPresentation.barQualifier returned the hyphenated cadence form ("7-day", "5-hour", "1-month") and sat beside the cadence token in the same caption row, so the Antigravity weekly row read "7d 7-day" on the Mac widget. Switch to short distinguishing nouns ("weekly", "monthly", "additional") kept under QuotaWindowVocabulary, and assert in WidgetPresentationVocabularyTests that none of those constants match a cadence form. --- Sources/QuotaCore/QuotaWindowVocabulary.swift | 13 ++++++++ Tests/CodeCapsTests/PipInteractionTests.swift | 15 ++++++++- .../WidgetPresentationVocabularyTests.swift | 31 +++++++++++++++++++ .../Widgets/WidgetPresentation.swift | 19 +++++++++--- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index 3938dc85..11bd2414 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -12,6 +12,19 @@ public enum QuotaWindowVocabulary { public static let oneMonth = "1-month" public static let otherLimit = "Other limit" + /// Short distinguishing nouns for the widget caption row. + /// + /// These sit *next to* the cadence token (`5h`, `7d`, `1m`) and must not + /// repeat its cadence form; "7-day" beside a `7d` token is the duplication + /// owner 2026-10-21 caught on the Antigravity weekly row. A noun that + /// names the window kind ("weekly", "monthly", "additional") is what the + /// caption needs when two bars share one cadence. + public static let barQualifierWeekly = "weekly" + public static let barQualifierMonthly = "monthly" + public static let barQualifierDaily = "daily" + public static let barQualifierHourly = "hourly" + public static let barQualifierAdditional = "additional" + /// Full cadence label for a window, suitable for main-window copy and wire export. public static func standardCadence( windowToken: String?, diff --git a/Tests/CodeCapsTests/PipInteractionTests.swift b/Tests/CodeCapsTests/PipInteractionTests.swift index baf8cc36..5a693b06 100644 --- a/Tests/CodeCapsTests/PipInteractionTests.swift +++ b/Tests/CodeCapsTests/PipInteractionTests.swift @@ -97,8 +97,21 @@ final class PipInteractionTests: XCTestCase { issue: model.issues[row.providerKey]).isEmpty) XCTAssertEqual(PipWidgetController.meterCount(for: row, now: model.now, issue: model.issues[row.providerKey]), 0) + // Unpinning releases the row, but the panel does not go blank: with no + // readable row the automatic selection falls back to status rows, the + // rule testAllUnavailableFallbackStillShowsStatusRows asserts. + // So the failed row can still be listed, and what matters is that + // every listed row is an "unavailable" status row with no meters, + // never the cached 54 percent the owner saw in a screenshot. model.pipPinnedRowIds = [] - XCTAssertFalse(PipWidgetController.targetRows(for: model).contains { $0.id == row.id }) + for candidate in PipWidgetController.targetRows(for: model) { + XCTAssertNotNil(PipWidgetController.readingStatus(for: candidate, now: model.now, + issue: model.issues[candidate.providerKey]), + "\(candidate.id) must carry a status, never a live meter") + XCTAssertTrue(PipWidgetController.meterSnapshots(for: candidate, now: model.now, + issue: model.issues[candidate.providerKey]).isEmpty, + "\(candidate.id) must not draw a cached meter") + } } func testStaleOrDisabledReadingCannotDrawCurrentMeters() throws { diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index f8ed92b6..b196ad13 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -21,4 +21,35 @@ final class WidgetPresentationVocabularyTests: XCTestCase { "7d" ) } + + /// Owner 2026-10-21 saw "7d 7-day" duplicate in the Mac widget caption row + /// for the Antigravity weekly allowance. The cadence token sits on the + /// same line as the qualifier, so the qualifier must NEVER repeat the + /// cadence form ("7-day" / "5-hour" / "1-month"). Short nouns ("weekly", + /// "monthly", "additional") are the only forms the caption supports. + func testBarQualifierConstantsAreShortNounsNotCadenceForms() { + XCTAssertEqual(QuotaWindowVocabulary.barQualifierWeekly, "weekly") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierMonthly, "monthly") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierDaily, "daily") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierHourly, "hourly") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierAdditional, "additional") + + for constant in [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierMonthly, + QuotaWindowVocabulary.barQualifierDaily, + QuotaWindowVocabulary.barQualifierHourly, + QuotaWindowVocabulary.barQualifierAdditional + ] { + for forbiddenHyphenated in [ + QuotaWindowVocabulary.sevenDay, + QuotaWindowVocabulary.fiveHour, + QuotaWindowVocabulary.oneMonth, + QuotaWindowVocabulary.otherLimit + ] { + XCTAssertNotEqual(constant, forbiddenHyphenated, + "qualifier '\(constant)' must not repeat the cadence form '\(forbiddenHyphenated)'.") + } + } + } } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index dbc63c6b..0cacfed4 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -63,13 +63,22 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { /// token (e.g. two "7d" bars). "Free weekly limit" reads "weekly", /// "Additional tokens" reads "additional". Empty when the window label /// carries no such qualifier. + /// + /// Owner 2026-10-21: the qualifier previously returned the hyphenated + /// cadence form ("7-day", "5-hour", "1-month") and sat beside the + /// cadence token in the same caption row โ€” a `7d 7-day` duplicate on the + /// Antigravity weekly row. Short nouns that do not repeat the cadence + /// are kept here, so two 7-day bars read "weekly" / "additional" (owner, + /// 2026-10-21). public var barQualifier: String { - if QuotaWindowVocabulary.isExtraAllowanceLabel(label) { return QuotaWindowVocabulary.otherLimit } + if QuotaWindowVocabulary.isExtraAllowanceLabel(label) { + return QuotaWindowVocabulary.barQualifierAdditional + } let text = label.lowercased() - if text.contains("weekly") { return QuotaWindowVocabulary.sevenDay } - if text.contains("monthly") || text.contains("billing") { return QuotaWindowVocabulary.oneMonth } - if text.contains("daily") { return "daily" } - if text.contains("hourly") { return QuotaWindowVocabulary.fiveHour } + if text.contains("weekly") { return QuotaWindowVocabulary.barQualifierWeekly } + if text.contains("monthly") || text.contains("billing") { return QuotaWindowVocabulary.barQualifierMonthly } + if text.contains("daily") { return QuotaWindowVocabulary.barQualifierDaily } + if text.contains("hourly") { return QuotaWindowVocabulary.barQualifierHourly } return "" } From 8bd932707941a02bc1d651f9534079dc84b3edb9 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 21 Oct 2026 09:42:37 +0000 Subject: [PATCH 32/50] fix(ios-fleet): validate screenshot-set mock request body The mock endpoint now rejects malformed JSON and non-string display types with 400. --- scripts/ios-fleet/test-asc-upload.mjs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs index a991cd9d..ae02ea88 100644 --- a/scripts/ios-fleet/test-asc-upload.mjs +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -85,7 +85,28 @@ function upload(behaviour, file = pngPath) { } if (req.url === "/v1/appScreenshotSets" && req.method === "POST") { // A set for a display type the server does not have yet. - const wanted = JSON.parse(body || "{}")?.data?.attributes?.screenshotDisplayType; + let wanted; + try { + const parsed = JSON.parse(body || "{}"); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + parsed.data === null || + typeof parsed.data !== "object" || + Array.isArray(parsed.data) || + parsed.data.attributes === null || + typeof parsed.data.attributes !== "object" || + Array.isArray(parsed.data.attributes) || + typeof parsed.data.attributes.screenshotDisplayType !== "string" || + parsed.data.attributes.screenshotDisplayType.trim().length === 0 + ) { + return json(400, { errors: [{ status: "400", code: "INVALID_REQUEST" }] }); + } + wanted = parsed.data.attributes.screenshotDisplayType; + } catch { + return json(400, { errors: [{ status: "400", code: "INVALID_REQUEST" }] }); + } // The response echoes `wanted`, so echoing it back cannot tell a // correct size-to-display-type mapping from a wrong one. Record what // was actually asked for so a test can assert on it directly. From 726646db3be7b3560d2d22f624945d22f01d3921 Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 07:28:54 -0500 Subject: [PATCH 33/50] CC-169: prefer a secret ASC key over a key file, and check the file first The App Store Connect client resolved a credential from ASC_KEY_PATH before it looked at a key that was already in the environment, so a path left over from an earlier step decided which credential signed the request. It also read whatever that path named and printed a masked preview of the content, which put part of an unrelated file into a log line when App Store Connect refused the credential. The order is now the rule: a secret-valued environment variable beats a file, and a file beats the network. APPLE_API_KEY_P8_BASE64 and ASC_KEY_P8 both carry the key itself and are used first. A file is used last, and only when it is a regular file, not a symlink, owner-only, small, and a PEM private key, so a path that names the wrong file is refused with the reason instead of read. scripts/ios-fleet/ship-testflight.sh gets the same order: an exported ASC_KEY_P8 is written to the mode-600 staging file and used, so an operator who handed the key to the process gets that key. The hosted runner never exports ASC_KEY_P8, so the runner's behaviour is unchanged. Verification: 46 node tests and the shell credential suite pass, including new cases for the precedence, a file that is not a private key, a key file other users can read, and a symlinked key path. The key files the suites use are now mode 600, which is what the client requires. --- scripts/ios-fleet/asc-api.mjs | 107 +++++++++++++++++---- scripts/ios-fleet/ship-testflight.sh | 20 +++- scripts/ios-fleet/test-asc-client.mjs | 95 +++++++++++++++++- scripts/ios-fleet/test-asc-credentials.mjs | 23 ++++- scripts/ios-fleet/test-asc-credentials.sh | 25 +++++ scripts/ios-fleet/test-asc-submit.mjs | 5 +- scripts/ios-fleet/test-asc-upload.mjs | 5 +- 7 files changed, 256 insertions(+), 24 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 79f8c5ee..a9c688ee 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -20,7 +20,7 @@ * echoing anything secret-shaped from the response (ASC responses don't * carry credentials, only app metadata, so this is safe to print as-is). */ -import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync, chmodSync, realpathSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync, chmodSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { createHash, createSign } from "node:crypto"; import { homedir, tmpdir } from "node:os"; @@ -193,32 +193,77 @@ function expandTilde(p) { return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; } -function fromFile(keyId, issuerId, keyPath, source) { +// Normalizes a PEM private key and refuses anything that is not one. +// +// The check matters because the key can arrive from a file or from an +// environment variable, and both end up in the masked preview that a refused +// request prints. A preview is only safe when the material behind it really +// is a private key. +function normalizePrivateKey(value) { + const pem = String(value || "").replace(/\r\n/g, "\n").trim(); + if (!pem) return null; + if (!/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(pem)) return null; + return pem.endsWith("\n") ? pem : `${pem}\n`; +} + +// The private key may legitimately live in a file: ios-stage-asc-key.sh keeps +// the multiline PEM out of GITHUB_ENV and publishes only a mode-600 path. +// +// That makes the path a credential source, so it gets rules. Without them the +// client reads whatever the path names, treats it as a key, and prints part of +// it into a log line when App Store Connect refuses the credential. +// +// * The file is read with lstat, so a symlink is refused, not followed. +// * It has to be a regular file, small, and a PEM private key, so a path +// that names the wrong file is refused instead of half-read. +// * Only its owner may read it. A private key other users can read is +// already leaked, and the staged key is mode 600. +// +// Returns { pem } or { error }. +function readPrivateKeyFile(keyPath) { + let info; try { - return { keyId: keyId.trim(), issuerId: issuerId.trim(), privateKeyPem: readFileSync(keyPath, "utf8"), source }; + info = lstatSync(keyPath); } catch { - return null; + return { error: `the key file ${keyPath} could not be read` }; + } + if (!info.isFile()) { + return { error: `the key path ${keyPath} is not a regular file` }; + } + if ((info.mode & 0o077) !== 0) { + const mode = (info.mode & 0o777).toString(8).padStart(3, "0"); + return { error: `the key file ${keyPath} is readable by others (mode ${mode}); a private key has to be owner-only (chmod 600)` }; + } + if (info.size > 64 * 1024) { + return { error: `the key file ${keyPath} is ${info.size} bytes, which is not a private key` }; + } + let raw; + try { + raw = readFileSync(keyPath, "utf8"); + } catch { + return { error: `the key file ${keyPath} could not be read` }; + } + const pem = normalizePrivateKey(raw); + if (!pem) { + return { error: `the key file ${keyPath} does not hold a PEM private key` }; } + return { pem }; } // Returns { keyId, issuerId, privateKeyPem, source } or { error } or { error: null }. +// +// The order is the rule: a secret-valued environment variable beats a file, +// and a file beats the network. A path on disk is the weakest source, so it +// must never decide which credential is used when the operator already handed +// the key to the process. function resolveCredential() { - // 1) Already-exported ASC_* (DealDex's workflow does this). const envKeyId = String(process.env.ASC_KEY_ID || "").trim(); const envIssuer = String(process.env.ASC_ISSUER_ID || "").trim(); const envKeyPath = expandTilde(String(process.env.ASC_KEY_PATH || "").trim()); - if (envKeyId && envIssuer && envKeyPath && existsSync(envKeyPath)) { - const got = fromFile(envKeyId, envIssuer, envKeyPath, "ASC_KEY_* environment"); - if (got) return got; - } - - // No file is read here (AFC-359). Local-Mac operators set ASC_KEY_ID / - // ASC_ISSUER_ID / ASC_KEY_PATH (or the APPLE_API_* names) in their shell, or - // let ship-testflight.sh stage the Infisical key and export ASC_* before - // it invokes this module (step 3 below also reads Infisical directly). The hosted ios-ship runner only has the APPLE_API_* - // form in $GITHUB_ENV. + const envKeyPem = String(process.env.ASC_KEY_P8 || "").trim(); - // 2) The APPLE_API_* trio the ship workflow exports from Infisical. + // 1) The APPLE_API_* trio the ship workflow exports from Infisical. The + // key itself is in the environment, so it wins over every file on disk. const keyId = String(process.env.APPLE_API_KEY_ID || "").trim(); const issuerId = String(process.env.APPLE_API_ISSUER_ID || "").trim(); if (keyId && issuerId && process.env.APPLE_API_KEY_P8_BASE64) { @@ -228,7 +273,29 @@ function resolveCredential() { return { keyId, issuerId, privateKeyPem: pem, source: "APPLE_API_* environment (base64 p8)" }; } - // 3) Infisical Fleet-OPS /asc: key id and issuer (unless the environment already + // 2) The same secret as a plain environment variable. This is the form + // fleet-secrets hands out, so an operator who exported the key gets that + // key used, not whatever ASC_KEY_PATH happens to point at. + if (envKeyId && envIssuer && envKeyPem) { + const pem = normalizePrivateKey(envKeyPem); + if (!pem) return { error: "ASC_KEY_P8 is set but is not a PEM private key" }; + return { keyId: envKeyId, issuerId: envIssuer, privateKeyPem: pem, source: "ASC_KEY_P8 environment" }; + } + + // 3) The file-backed form. Last, because it is the weakest source, and + // checked, because a path that names the wrong file would put that + // file's content into a log line. + if (envKeyId && envIssuer && envKeyPath) { + const read = readPrivateKeyFile(envKeyPath); + if (read.error) return { error: read.error }; + return { keyId: envKeyId, issuerId: envIssuer, privateKeyPem: read.pem, source: "ASC_KEY_PATH environment" }; + } + + // No file is read here (AFC-359). A file is read only when the operator + // names one in ASC_KEY_PATH, and never a default path. Step 4 reads + // Infisical directly, so a local operator needs nothing exported at all. + + // 4) Infisical Fleet-OPS /asc: key id and issuer (unless the environment already // has them) and the PEM as ASC_KEY_P8 when the key id is the /asc one; another // key id falls back to AUTHKEY_ in /authkey--p8. // Kept in memory, never written to disk. @@ -757,8 +824,10 @@ async function main() { if (!cred.privateKeyPem) { console.error( "asc-auth: no App Store Connect credential found. Tried, in order:\n" + - " 1) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH in the environment\n" + - " 2) APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 in the environment" + " 1) APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 in the environment\n" + + " 2) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 in the environment\n" + + " 3) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH in the environment\n" + + " 4) Fleet-OPS prod /asc through fleet-secrets" ); process.exit(1); } diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 43254fae..5132cd33 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -208,6 +208,25 @@ fs_get() { # ASC_KEY_PATH/ASC_KEY_ID/ASC_ISSUER_ID in the environment, else Infisical. load_secrets() { AUTH_MODE="none" + local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower + # A secret-valued environment variable beats a path on disk. ASC_KEY_P8 is + # what fleet-secrets hands out, so an operator who exported the key gets + # that key used; a leftover ASC_KEY_PATH must not decide which credential + # signs the upload. The hosted runner never exports ASC_KEY_P8 (the + # multiline PEM stays out of GITHUB_ENV), so this branch is the local + # operator's path and leaves the runner's behaviour untouched. + if [[ -n "$kid" && -n "$iss" && -n "${ASC_KEY_P8:-}" ]]; then + if [[ -z "$ASC_TMP_DIR" ]]; then + ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" + fi + ( umask 077; printf '%s\n' "$ASC_KEY_P8" > "${ASC_TMP_DIR}/AuthKey_${kid}.p8" ) + chmod 600 "${ASC_TMP_DIR}/AuthKey_${kid}.p8" + export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="${ASC_TMP_DIR}/AuthKey_${kid}.p8" + export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" + ASC_KEY_P8="" + AUTH_MODE="api_key" + return 0 + fi if [[ -n "${ASC_KEY_ID:-}" && -n "${ASC_ISSUER_ID:-}" && -n "${ASC_KEY_PATH:-}" ]]; then if [[ -f "$(expand_tilde "$ASC_KEY_PATH")" ]]; then AUTH_MODE="api_key" @@ -215,7 +234,6 @@ load_secrets() { fi log "ASC_KEY_PATH set but file missing (not printing path contents)" fi - local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower # The key id, issuer and private key must belong together: with nothing named in the # environment all three come from /asc; an env key id that is not the /asc one gets its # own .p8 from /authkey--p8, never the /asc key. diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs index 271c8ea7..60986266 100644 --- a/scripts/ios-fleet/test-asc-client.mjs +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -11,7 +11,7 @@ // port, with a synthetic key. No network, no fleet-secrets, no real secret. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { generateKeyPairSync } from "node:crypto"; import { tmpdir } from "node:os"; @@ -26,6 +26,10 @@ const root = mkdtempSync(join(tmpdir(), "asc-client-test-")); const keyPath = join(root, "synthetic.p8"); writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey .export({ type: "pkcs8", format: "pem" })); +// A private key only its owner may read, which is what the client now +// requires of a file it is pointed at. The staged key that +// ios-stage-asc-key.sh publishes is mode 600 for the same reason. +chmodSync(keyPath, 0o600); const PROD = "https://api.appstoreconnect.apple.com"; @@ -37,6 +41,13 @@ function run(args, env = {}) { ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", ASC_KEY_PATH: keyPath, + // The credential sources are cleared so an ambient ASC_KEY_P8 or + // APPLE_API_* on the machine running the suite cannot decide which + // credential a test gets. + ASC_KEY_P8: "", + APPLE_API_KEY_ID: "", + APPLE_API_ISSUER_ID: "", + APPLE_API_KEY_P8_BASE64: "", ...env }, stdio: ["ignore", "pipe", "pipe"] @@ -220,6 +231,7 @@ test("two different keys do not get the same masked preview", async () => { const otherKeyPath = join(root, "other-synthetic.p8"); writeFileSync(otherKeyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey .export({ type: "pkcs8", format: "pem" })); + chmodSync(otherKeyPath, 0o600); const server = createServer((req, res) => { res.writeHead(401, { "Content-Type": "text/plain" }); @@ -272,4 +284,85 @@ test("a large payload is not truncated on the way out", async () => { result.stdout.length >= body.length, `the payload was cut short: ${result.stdout.length} of ${body.length} bytes arrived` ); +}); + +test("a secret in the environment wins over a key file", async () => { + // Two credential sources are present: the key the operator exported and a + // path left over from an earlier step. The secret has to be the one used, + // because a file is the weakest source and the path must never decide which + // credential signs the request. + const secretPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["GET", "/v1/apps"], { + ASC_API_BASE: base, + ASC_KEY_P8: secretPem, + ASC_KEY_PATH: join(root, "never-read.p8") + }); + } finally { + server.close(); + } + assert.match( + result.stderr, + /asc-auth: using ASC_KEY_P8 environment/, + `the exported secret was not the credential in play:\n${result.stderr}` + ); + assert.doesNotMatch(result.stderr, /using ASC_KEY_PATH environment/); +}); + +test("a key file that is not a private key is refused, and its content stays out of the log", async () => { + // The path names a real file, so the client used to read it, treat it as + // key material, and print a masked preview of it when App Store Connect + // refused the credential. A path that names the wrong file has to be + // refused instead, and the file's content must never reach a log line. + const notAKey = join(root, "not-a-key.txt"); + writeFileSync(notAKey, "hunter2 this is not a private key\n"); + chmodSync(notAKey, 0o600); + let requests = 0; + const server = createServer((req, res) => { + requests += 1; + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["GET", "/v1/apps"], { ASC_API_BASE: base, ASC_KEY_PATH: notAKey }); + } finally { + server.close(); + } + assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); + assert.match(result.stderr, /does not hold a PEM private key/); + assert.doesNotMatch(result.stderr, /hunter2/, "the refused file's content reached the log"); + assert.equal(requests, 0, "a refused credential must not reach App Store Connect"); +}); + +test("a key file other users can read is refused", async () => { + // A private key another user can read is already leaked, so the client + // must not treat it as usable key material in the first place. + const loose = join(root, "loose.p8"); + writeFileSync(loose, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" })); + chmodSync(loose, 0o644); + const result = await run(["GET", "/v1/apps"], { ASC_KEY_PATH: loose }); + assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); + assert.match(result.stderr, /readable by others/); +}); + +test("a symlinked key path is refused rather than followed", async () => { + // A symlink is followed by every ordinary file read, so it would let a + // path that looks like a key file resolve to something else entirely. + const link = join(root, "linked.p8"); + symlinkSync(keyPath, link); + const result = await run(["GET", "/v1/apps"], { ASC_KEY_PATH: link }); + assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); + assert.match(result.stderr, /is not a regular file/); }); \ No newline at end of file diff --git a/scripts/ios-fleet/test-asc-credentials.mjs b/scripts/ios-fleet/test-asc-credentials.mjs index e1b788c9..4696dcb4 100644 --- a/scripts/ios-fleet/test-asc-credentials.mjs +++ b/scripts/ios-fleet/test-asc-credentials.mjs @@ -102,14 +102,35 @@ test("an env key id with no key material anywhere fails closed", async () => { test("a complete environment (id, issuer, key file) wins and never calls fleet-secrets", async () => { const keyFile = join(root, "env-key.p8"); writeFileSync(keyFile, pem()); + chmodSync(keyFile, 0o600); const r = await run({ ASC_KEY_ID: "ENVKEY0001", ASC_ISSUER_ID: "env-issuer", ASC_KEY_PATH: keyFile, FLEET_SECRETS: join(root, "does-not-exist") }); assert.equal(r.code, 0); assert.equal(r.kid, "ENVKEY0001"); assert.equal(r.iss, "env-issuer"); }); +test("an exported secret wins over a key file, so an unusable path cannot break the run", async () => { + // The path names a file that is not a private key, so it would be refused + // if it were consulted. The secret has to be used instead, because a + // path on disk is the weakest source and must never decide which + // credential signs the request. + const notAKey = join(root, "not-a-key.p8"); + writeFileSync(notAKey, "this is not a private key\n"); + chmodSync(notAKey, 0o600); + const r = await run({ + ASC_KEY_ID: "ENVKEY0002", + ASC_ISSUER_ID: "env-issuer", + ASC_KEY_P8: pem(), + ASC_KEY_PATH: notAKey + }); + assert.equal(r.code, 0); + assert.equal(r.kid, "ENVKEY0002"); +}); + test("only a leading ~/ is expanded in ASC_KEY_PATH", async () => { - writeFileSync(join(home, "tilde-key.p8"), pem()); + const tildeKey = join(home, "tilde-key.p8"); + writeFileSync(tildeKey, pem()); + chmodSync(tildeKey, 0o600); const gone = join(root, "does-not-exist"); const ok = await run({ ASC_KEY_ID: "TILDEKEY01", ASC_ISSUER_ID: "tilde-issuer", ASC_KEY_PATH: "~/tilde-key.p8", FLEET_SECRETS: gone }); assert.equal(ok.code, 0); diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index ade9f0c4..84ba485c 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -81,6 +81,31 @@ env ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer ASC_KEY_PATH="${tmp}/env-key. ' || fail "environment credentials were not honored" [[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" +# 2a. A secret in the environment beats a key file: the operator handed the key to the +# process, so a leftover path must not decide which credential signs the upload. The file +# here is deliberately unusable, so a run that consulted it would fail. +: > "$FAKE_LOG" +printf 'not a private key\n' > "${tmp}/not-a-key.p8" +chmod 600 "${tmp}/not-a-key.p8" +secret_pem='-----BEGIN PRIVATE KEY----- +SECRETMATERIAL +-----END PRIVATE KEY-----' +out="$(env ASC_KEY_ID=ENVKEY0002 ASC_ISSUER_ID=env-issuer ASC_KEY_P8="$secret_pem" \ + ASC_KEY_PATH="${tmp}/not-a-key.p8" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key ]] + [[ "$ASC_KEY_ID" == ENVKEY0002 && "$ASC_ISSUER_ID" == env-issuer ]] + grep -q SECRETMATERIAL "$ASC_KEY_PATH" + mode="$(stat -c %a "$ASC_KEY_PATH" 2>/dev/null || stat -f %Lp "$ASC_KEY_PATH")" + [[ "$mode" == 600 ]] + echo "the exported secret was the key in play" + cleanup_asc_tmp +')" || fail "the exported secret did not win over the key file" +[[ "$out" == *"the exported secret was the key in play"* ]] || fail "the exported secret was not the key in play" +[[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" + # 2b. An env key id that is NOT the /asc one never gets the /asc key: its own .p8 comes from # /authkey--p8, and /asc ASC_KEY_P8 is not requested. : > "$FAKE_LOG" diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 3d702a2a..8e5e201f 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -6,7 +6,7 @@ // server: no network, no fleet-secrets, no real secret. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { generateKeyPairSync } from "node:crypto"; import { tmpdir } from "node:os"; @@ -21,6 +21,9 @@ const root = mkdtempSync(join(tmpdir(), "asc-submit-test-")); const keyPath = join(root, "synthetic.p8"); writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey .export({ type: "pkcs8", format: "pem" })); +// A private key only its owner may read, which is what the client now +// requires of a file it is pointed at. +chmodSync(keyPath, 0o600); const VERSION_ID = "version-1"; const APP_ID = "app-1"; diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs index ae02ea88..b0a4b63d 100644 --- a/scripts/ios-fleet/test-asc-upload.mjs +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -11,7 +11,7 @@ // and without a real secret anywhere. No network, no fleet-secrets. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { generateKeyPairSync } from "node:crypto"; import { tmpdir } from "node:os"; @@ -28,6 +28,9 @@ const root = mkdtempSync(join(tmpdir(), "asc-upload-test-")); const keyPath = join(root, "synthetic.p8"); writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey .export({ type: "pkcs8", format: "pem" })); +// A private key only its owner may read, which is what the client now +// requires of a file it is pointed at. +chmodSync(keyPath, 0o600); // A stub PNG: the script only reads the IHDR width and height out of bytes // 16..23 to decide which screenshot set the file belongs to, and never looks From 8ed529015d87804846113856833cf17e5f9b9e50 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:48:14 -0500 Subject: [PATCH 34/50] fix(pip): make the failed-provider fallback assertion non-vacuous The loop over the automatic target set could pass with no rows at all, and it required every listed row to carry a status, which the selection contradicts for a row that has a live reading. The rows are bound and asserted non-empty, the failed provider is asserted absent from the set, and each row is checked in both directions: a row whose provider failed carries a status and draws no meters, a row with a live reading shows its meters, and a row that can only report a status draws none. The fixture now keeps the demo readings beside the failed provider, so the readable case is exercised too. --- Tests/CodeCapsTests/PipInteractionTests.swift | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/Tests/CodeCapsTests/PipInteractionTests.swift b/Tests/CodeCapsTests/PipInteractionTests.swift index 5a693b06..8721d645 100644 --- a/Tests/CodeCapsTests/PipInteractionTests.swift +++ b/Tests/CodeCapsTests/PipInteractionTests.swift @@ -83,8 +83,15 @@ final class PipInteractionTests: XCTestCase { func testFailedAntigravityReadingStaysPinnedWithoutShowingCachedQuota() throws { let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: false, fleet: false) defer { defaults.removePersistentDomain(forName: suite) } - let windows = [GlanceFixtures.window("antigravity:gemini:weekly", provider: "google-antigravity", - label: "Gemini Models ยท Weekly", token: "weekly", remaining: 54, resetIn: 86_400)] + // The demo model plus a failed Antigravity provider. The weekly + // reading is the cached 54 percent the owner saw in a screenshot, and + // the provider fails to refresh it. + let windows = GlanceFixtures.localWindows.map { window in + window.id == "antigravity:gemini:weekly" + ? GlanceFixtures.window("antigravity:gemini:weekly", provider: "google-antigravity", + label: "Gemini Models ยท Weekly", token: "weekly", remaining: 54, resetIn: 86_400) + : window + } let sections = QuotaResponse(generatedAt: "", windows: windows).platformSections(now: GlanceFixtures.now) model.injectForTests(sections: sections, now: GlanceFixtures.now, issues: ["google-antigravity": "Helper failed"]) @@ -97,20 +104,31 @@ final class PipInteractionTests: XCTestCase { issue: model.issues[row.providerKey]).isEmpty) XCTAssertEqual(PipWidgetController.meterCount(for: row, now: model.now, issue: model.issues[row.providerKey]), 0) - // Unpinning releases the row, but the panel does not go blank: with no - // readable row the automatic selection falls back to status rows, the - // rule testAllUnavailableFallbackStillShowsStatusRows asserts. - // So the failed row can still be listed, and what matters is that - // every listed row is an "unavailable" status row with no meters, - // never the cached 54 percent the owner saw in a screenshot. + // Unpinning releases the row, but the panel does not go blank: the + // automatic selection falls back to the rows it can honestly measure, + // the rule testAllUnavailableFallbackStillShowsStatusRows asserts. + // What matters is that the failed provider never comes back with the + // cached 54 percent, and that every listed row is drawn honestly. model.pipPinnedRowIds = [] - for candidate in PipWidgetController.targetRows(for: model) { - XCTAssertNotNil(PipWidgetController.readingStatus(for: candidate, now: model.now, - issue: model.issues[candidate.providerKey]), - "\(candidate.id) must carry a status, never a live meter") - XCTAssertTrue(PipWidgetController.meterSnapshots(for: candidate, now: model.now, - issue: model.issues[candidate.providerKey]).isEmpty, - "\(candidate.id) must not draw a cached meter") + let rows = PipWidgetController.targetRows(for: model) + XCTAssertFalse(rows.isEmpty, "the panel must not go blank when the failed row is released") + XCTAssertFalse(rows.contains { $0.id == row.id }, + "the failed row must not be listed: it would show the cached 54 percent") + for candidate in rows { + let candidateIssue = model.issues[candidate.providerKey] + let status = PipWidgetController.readingStatus(for: candidate, now: model.now, + issue: candidateIssue) + let meters = PipWidgetController.meterSnapshots(for: candidate, now: model.now, + issue: candidateIssue) + if candidateIssue != nil { + XCTAssertNotNil(status, "\(candidate.id) must carry a status, never a live meter") + XCTAssertTrue(meters.isEmpty, "\(candidate.id) must not draw a cached meter") + } else if status == nil { + XCTAssertFalse(meters.isEmpty, "\(candidate.id) must show its live meters") + } else { + XCTAssertTrue(meters.isEmpty, + "\(candidate.id) must not draw meters for a row that only reports a status") + } } } From 4720d1416c5e4fe90839365ba47c33b704705ecf Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:45:39 -0500 Subject: [PATCH 35/50] fix(widget): keep the bar qualifier off the cadence and off a lone bar The widget caption row printed a cadence word beside the cadence token it already showed, and it printed that word even when no other bar shared the cadence. The qualifier vocabulary now lives in QuotaWindowVocabulary, which every surface shares, so the rules are stated once and tested in the package: - the daily qualifier reads "daily allowance", so a daily window reads "1d daily allowance" and never "1d daily" - the qualifiers match exact lexical tokens, so a label such as "nothourly" is not an hourly window - the qualifier is resolved by the platform item that owns the sibling windows, so a lone bar prints none and two bars on one cadence still read "weekly" and "additional" The guard test now runs against the shared forbidden list, which covers the daily cadence form and the compact cadence tokens the caption row already shows. --- Sources/QuotaCore/QuotaWindowVocabulary.swift | 95 +++++++++++- .../WidgetPresentationVocabularyTests.swift | 135 ++++++++++++++++-- .../Widgets/WidgetPresentation.swift | 76 +++++++--- 3 files changed, 273 insertions(+), 33 deletions(-) diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index 11bd2414..48fa25cc 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -21,10 +21,103 @@ public enum QuotaWindowVocabulary { /// caption needs when two bars share one cadence. public static let barQualifierWeekly = "weekly" public static let barQualifierMonthly = "monthly" - public static let barQualifierDaily = "daily" + /// Not "daily": the caption already shows the `1d` token, so the + /// qualifier must not restate the same cadence. "daily allowance" names + /// the kind of allowance instead. + public static let barQualifierDaily = "daily allowance" public static let barQualifierHourly = "hourly" public static let barQualifierAdditional = "additional" + /// The cadence forms a bar qualifier must never repeat: the hyphenated + /// labels and the compact tokens the caption row already carries beside + /// the qualifier. + public static let barQualifierForbiddenCadenceForms = [ + fiveHour, oneDay, sevenDay, oneMonth, otherLimit, + "5h", "1d", "7d", "1m", "+" + ] + + /// The caption facts of one quota bar: the cadence it shows and the label + /// it carries. A row of bars is resolved together, because whether a bar + /// needs a qualifier depends on the bars beside it. + public struct BarCaption: Equatable, Sendable { + public let cadence: String + public let label: String + + public init(cadence: String, label: String) { + self.cadence = cadence + self.label = label + } + } + + /// The lowercased lexical tokens of a label, split on everything that is + /// not a letter or a number. + /// + /// Canonical qualifiers are matched against these tokens and never as + /// substrings, so a label such as "nothourly" is not an hourly window. + public static func lexicalTokens(_ text: String) -> Set { + Set( + text.lowercased() + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .map(String.init) + ) + } + + /// The short distinguishing noun for a bar caption row, or "" when the + /// label names none. + /// + /// "Free weekly limit" reads "weekly", "Additional tokens" reads + /// "additional", and a label with no such word reads nothing. + public static func barQualifier(forLabel label: String) -> String { + if isExtraAllowanceLabel(label) { return barQualifierAdditional } + let tokens = lexicalTokens(label) + if tokens.contains("weekly") { return barQualifierWeekly } + if tokens.contains("monthly") || tokens.contains("billing") { return barQualifierMonthly } + if tokens.contains("daily") { return barQualifierDaily } + if tokens.contains("hourly") { return barQualifierHourly } + return "" + } + + /// The qualifier for a bar caption row, or "" when the row needs none. + /// + /// A cadence word is only worth printing when a sibling bar shares the + /// cadence, because on a lone bar "7d weekly" says the same thing twice. + public static func barQualifier(forLabel label: String, sharesCadence: Bool) -> String { + guard sharesCadence else { return "" } + return barQualifier(forLabel: label) + } + + /// The canonical cadence a window belongs to, used to group the bars that + /// share one period. + public static func barCadenceGroup(cadence: String, label: String) -> String { + let tokens = lexicalTokens(label) + if tokens.contains("weekly") { return sevenDay } + if tokens.contains("monthly") || tokens.contains("billing") { return oneMonth } + if tokens.contains("daily") { return oneDay } + if tokens.contains("hourly") { return fiveHour } + if isExtraAllowanceLabel(label) { + // An extra allowance is extra capacity of the period it doubles, + // so it groups with that window rather than standing alone. + return standardCadence(windowToken: cadence, label: "") + } + return standardCadence(windowToken: cadence, label: label) + } + + /// The qualifier of every bar in a caption row, in the same order. + /// + /// Each bar is resolved against its siblings, so a bar that shares its + /// cadence with another bar gets the word that tells the two apart and a + /// lone bar gets none. + public static func barQualifiers(forBars bars: [BarCaption]) -> [String] { + let groups = bars.map { barCadenceGroup(cadence: $0.cadence, label: $0.label) } + return bars.indices.map { index in + let siblings = groups.indices.filter { $0 != index }.map { groups[$0] } + return barQualifier( + forLabel: bars[index].label, + sharesCadence: siblings.contains(groups[index]) + ) + } + } + /// Full cadence label for a window, suitable for main-window copy and wire export. public static func standardCadence( windowToken: String?, diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index b196ad13..07dd34b7 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -27,10 +27,16 @@ final class WidgetPresentationVocabularyTests: XCTestCase { /// same line as the qualifier, so the qualifier must NEVER repeat the /// cadence form ("7-day" / "5-hour" / "1-month"). Short nouns ("weekly", /// "monthly", "additional") are the only forms the caption supports. + /// + /// Kody review 2026-10-21: the forbidden list named "7-day", "5-hour", + /// "1-month" and "Other limit" but left the daily cadence and the compact + /// tokens ("1d", "7d", "5h", "1m", "+") unguarded, so the stated invariant + /// was not actually enforced. The list below is the shared one the + /// vocabulary ships. func testBarQualifierConstantsAreShortNounsNotCadenceForms() { XCTAssertEqual(QuotaWindowVocabulary.barQualifierWeekly, "weekly") XCTAssertEqual(QuotaWindowVocabulary.barQualifierMonthly, "monthly") - XCTAssertEqual(QuotaWindowVocabulary.barQualifierDaily, "daily") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierDaily, "daily allowance") XCTAssertEqual(QuotaWindowVocabulary.barQualifierHourly, "hourly") XCTAssertEqual(QuotaWindowVocabulary.barQualifierAdditional, "additional") @@ -41,15 +47,126 @@ final class WidgetPresentationVocabularyTests: XCTestCase { QuotaWindowVocabulary.barQualifierHourly, QuotaWindowVocabulary.barQualifierAdditional ] { - for forbiddenHyphenated in [ - QuotaWindowVocabulary.sevenDay, - QuotaWindowVocabulary.fiveHour, - QuotaWindowVocabulary.oneMonth, - QuotaWindowVocabulary.otherLimit - ] { - XCTAssertNotEqual(constant, forbiddenHyphenated, - "qualifier '\(constant)' must not repeat the cadence form '\(forbiddenHyphenated)'.") + for forbiddenCadenceForm in QuotaWindowVocabulary.barQualifierForbiddenCadenceForms { + XCTAssertNotEqual(constant, forbiddenCadenceForm, + "qualifier '\(constant)' must not repeat the cadence form '\(forbiddenCadenceForm)'.") } } + + XCTAssertTrue(QuotaWindowVocabulary.barQualifierForbiddenCadenceForms.contains(QuotaWindowVocabulary.oneDay), + "the daily cadence form must stay on the forbidden list.") + for compactToken in ["5h", "1d", "7d", "1m", "+"] { + XCTAssertTrue(QuotaWindowVocabulary.barQualifierForbiddenCadenceForms.contains(compactToken), + "the compact cadence token '\(compactToken)' must stay on the forbidden list.") + } + } + + /// Kody review 2026-10-21: the daily qualifier restated the cadence the + /// caption token already showed, so a daily window read "1d daily". The + /// qualifier names the kind of allowance instead. + func testDailyQualifierDoesNotRestateTheCadenceToken() { + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap", sharesCadence: true), + "daily allowance" + ) + XCTAssertNotEqual( + QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap", sharesCadence: true), + "daily" + ) + + let token = QuotaWindowVocabulary.popoverCaption(windowToken: "Daily", label: "Daily cap") + XCTAssertEqual(token, "1d") + let caption = "\(token) \(QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap", sharesCadence: true))" + XCTAssertEqual(caption, "1d daily allowance") + XCTAssertNotEqual(caption, "1d daily") + } + + /// Kody rule "Avoid Substring Matching for Canonical Limit/Window Labels" + /// (binding, 2026-10-21): a canonical qualifier is matched on an exact + /// lexical token, never as a substring, so "nothourly" is not an hourly + /// window. + func testBarQualifierMatchesExactTokensNotSubstrings() { + XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "nothourly"), "") + XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "notweekly"), "") + XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "notmonthly"), "") + XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "notdaily"), "") + XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "unhourlyplan"), "") + + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier(forLabel: "Hourly plan"), + QuotaWindowVocabulary.barQualifierHourly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier(forLabel: "Weekly cap"), + QuotaWindowVocabulary.barQualifierWeekly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier(forLabel: "Monthly plan"), + QuotaWindowVocabulary.barQualifierMonthly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap"), + QuotaWindowVocabulary.barQualifierDaily + ) + } + + /// Kody review 2026-10-21: a lone bar printing a cadence word says the + /// same thing twice, so the qualifier is only printed when a sibling bar + /// shares the cadence. + func testLoneCadenceBarPrintsNoQualifierAndSharedCadenceDoes() { + let lone = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "Weekly", label: "Weekly cap") + ]) + XCTAssertEqual(lone, [""]) + + // The Antigravity weekly bar sits beside a 5-hour bar, a different + // cadence, so neither bar needs a qualifier. + let mixedCadences = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "5h", label: "5-hour window"), + QuotaWindowVocabulary.BarCaption(cadence: "Weekly", label: "Weekly cap") + ]) + XCTAssertEqual(mixedCadences, ["", ""]) + + // Two 7-day bars share one cadence, so they read "weekly" and + // "additional" (owner-approved, 2026-10-21). + let sharedCadence = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7d", label: "Free weekly limit"), + QuotaWindowVocabulary.BarCaption(cadence: "7d", label: "Additional allowance") + ]) + XCTAssertEqual(sharedCadence, [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierAdditional + ]) + + // Two daily bars share one cadence and read the allowance word. + let sharedDaily = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "Daily", label: "Daily cap"), + QuotaWindowVocabulary.BarCaption(cadence: "Daily", label: "Daily allowance cap") + ]) + XCTAssertEqual(sharedDaily, [ + QuotaWindowVocabulary.barQualifierDaily, + QuotaWindowVocabulary.barQualifierDaily + ]) + } + + /// The cadence group is what decides whether two bars share a cadence, so + /// it has to follow the period rather than the wording. + func testBarCadenceGroupFollowsThePeriod() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Weekly", label: "Weekly cap"), + QuotaWindowVocabulary.sevenDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "5h", label: "5-hour window"), + QuotaWindowVocabulary.fiveHour + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Daily", label: "Daily cap"), + QuotaWindowVocabulary.oneDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "1m", label: "Billing cycle"), + QuotaWindowVocabulary.oneMonth + ) } } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index 0cacfed4..767fc54d 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -14,6 +14,22 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { public let isExhausted: Bool public let isMasked: Bool public let observedAt: Date? + /// Short distinguishing label for a bar when two bars share one cadence + /// token (e.g. two "7d" bars). "Free weekly limit" reads "weekly", + /// "Additional tokens" reads "additional". Empty when the row needs no + /// qualifier. + /// + /// Owner 2026-10-21: the qualifier previously returned the hyphenated + /// cadence form ("7-day", "5-hour", "1-month") and sat beside the + /// cadence token in the same caption row, which made a `7d 7-day` + /// duplicate on the Antigravity weekly row. Short nouns that do not + /// repeat the cadence are kept here, so two 7-day bars read "weekly" / + /// "additional" (owner, 2026-10-21). + /// + /// The qualifier is resolved by the platform item that owns the sibling + /// windows, because a lone bar printing "7d weekly" says the same thing + /// twice (owner 2026-10-21). + public let barQualifier: String public init( id: String, @@ -23,7 +39,8 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { resetAt: Date?, isExhausted: Bool = false, isMasked: Bool = false, - observedAt: Date? = nil + observedAt: Date? = nil, + barQualifier: String = "" ) { self.id = id self.label = label @@ -33,6 +50,7 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { self.isExhausted = isExhausted self.isMasked = isMasked self.observedAt = observedAt + self.barQualifier = barQualifier } private static let staleAfter: TimeInterval = 30 * 60 @@ -59,27 +77,20 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { WidgetPresentation.shortCadence(cadence: cadence, label: label) } - /// Short distinguishing label for a bar when two bars share one cadence - /// token (e.g. two "7d" bars). "Free weekly limit" reads "weekly", - /// "Additional tokens" reads "additional". Empty when the window label - /// carries no such qualifier. - /// - /// Owner 2026-10-21: the qualifier previously returned the hyphenated - /// cadence form ("7-day", "5-hour", "1-month") and sat beside the - /// cadence token in the same caption row โ€” a `7d 7-day` duplicate on the - /// Antigravity weekly row. Short nouns that do not repeat the cadence - /// are kept here, so two 7-day bars read "weekly" / "additional" (owner, - /// 2026-10-21). - public var barQualifier: String { - if QuotaWindowVocabulary.isExtraAllowanceLabel(label) { - return QuotaWindowVocabulary.barQualifierAdditional - } - let text = label.lowercased() - if text.contains("weekly") { return QuotaWindowVocabulary.barQualifierWeekly } - if text.contains("monthly") || text.contains("billing") { return QuotaWindowVocabulary.barQualifierMonthly } - if text.contains("daily") { return QuotaWindowVocabulary.barQualifierDaily } - if text.contains("hourly") { return QuotaWindowVocabulary.barQualifierHourly } - return "" + /// A copy of this window that prints the given qualifier in its caption + /// row. The platform item calls this once it knows the sibling windows. + func withBarQualifier(_ qualifier: String) -> WidgetWindowItem { + WidgetWindowItem( + id: id, + label: label, + cadence: cadence, + remainingPercent: remainingPercent, + resetAt: resetAt, + isExhausted: isExhausted, + isMasked: isMasked, + observedAt: observedAt, + barQualifier: qualifier + ) } /// `Resets in 12d 22m`, or an empty string when the provider reports no reset. @@ -145,11 +156,30 @@ public struct WidgetPlatformItem: Identifiable, Equatable, Sendable { self.resetAt = resetAt self.isExhausted = isExhausted self.isMasked = isMasked - self.windows = windows + self.windows = WidgetPlatformItem.windowsResolvingBarQualifiers(windows) self.iconHint = iconHint self.defaultWindowLabel = defaultWindowLabel } + /// Resolve every window's caption qualifier against its siblings. + /// + /// The qualifier is a cadence word, and a cadence word is only worth + /// printing when another bar in the same platform shares the cadence: on + /// a lone bar the widget printed "7d weekly", which says the same thing + /// twice. The resolution lives here, where the sibling windows are + /// known, and not on the single window (owner 2026-10-21). + private static func windowsResolvingBarQualifiers(_ windows: [WidgetWindowItem]) -> [WidgetWindowItem] { + guard windows.count > 1 else { return windows } + let qualifiers = QuotaWindowVocabulary.barQualifiers( + forBars: windows.map { + QuotaWindowVocabulary.BarCaption(cadence: $0.cadence, label: $0.label) + } + ) + return windows.indices.map { index in + windows[index].withBarQualifier(qualifiers[index]) + } + } + public var displayPercent: String { WidgetPresentation.displayPercent(percent: remainingPercent, isMasked: isMasked) } From 0ab0a05c35a3f680e6a17b63ec77ebea73b0a825 Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 09:11:39 -0500 Subject: [PATCH 36/50] CC-169: hand the ASC key to the client through the environment Kody rule 42 on PR #271: a caller-supplied file is not an approved secret source, so asc-api.mjs no longer reads a key file. Its sources are the environment and fleet-secrets get ASC_KEY_P8. The shell layer that already owns the credential reads the staged key and exports it as ASC_KEY_P8, which is where the client looks for it, so the hosted runner path keeps working without the workflow changing. The staged key is opened once with O_NOFOLLOW and read from that descriptor, so a path that is swapped for a symlink cannot be followed, and a path that names nothing falls through to the fleet credential instead of aborting the run. A PEM is parsed before it is accepted, so a truncated key or a mangled export is a credential error instead of a stack trace from the signer. An ambient ASC_KEY_P8 is validated as a key and needs its key id and issuer, and it never replaces a named key file, so a stale value in an operator's shell cannot hijack the credential. No credential travels in a command argument any more. --- .../ios-fleet/appstore-connect.env.example | 5 + scripts/ios-fleet/asc-api.mjs | 122 ++++-------- scripts/ios-fleet/asc-key.mjs | 165 +++++++++++++++++ scripts/ios-fleet/ship-testflight.sh | 156 ++++++++++------ scripts/ios-fleet/test-asc-client.mjs | 102 +++++----- scripts/ios-fleet/test-asc-credentials.mjs | 43 +++-- scripts/ios-fleet/test-asc-credentials.sh | 167 ++++++++++++----- scripts/ios-fleet/test-asc-key.test.mjs | 174 ++++++++++++++++++ scripts/ios-fleet/test-asc-submit.mjs | 13 +- scripts/ios-fleet/test-asc-upload.mjs | 15 +- 10 files changed, 694 insertions(+), 268 deletions(-) create mode 100644 scripts/ios-fleet/asc-key.mjs create mode 100644 scripts/ios-fleet/test-asc-key.test.mjs diff --git a/scripts/ios-fleet/appstore-connect.env.example b/scripts/ios-fleet/appstore-connect.env.example index 6c1fa653..0babe853 100644 --- a/scripts/ios-fleet/appstore-connect.env.example +++ b/scripts/ios-fleet/appstore-connect.env.example @@ -11,7 +11,12 @@ ASC_KEY_ID=XXXXXXXXXX ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Path to a 0600 file holding the .p8 (CI stages one in the runner temp dir). +# ship-testflight.sh reads this file and hands the key to asc-api.mjs in +# ASC_KEY_P8: the client never reads a path, because a path on disk is not an +# approved secret source. ASC_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8 +# The .p8 contents themselves. asc-api.mjs takes the key from here. +# ASC_KEY_P8="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" # Optional Apple ID path (only if not using API key): # APPLE_ID=you@example.com diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index a9c688ee..84d5447c 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -5,10 +5,17 @@ * * Auth: resolves credentials from the first source that is present * (never prints values): - * 1. ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH in the environment - * 2. APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 - * (hosted ios-ship / Infisical; p8 is base64, decoded to a 0600 temp - * file and removed on exit) + * 1. APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 + * (hosted ios-ship / Infisical; p8 is base64) + * 2. ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 in the environment + * 3. Fleet-OPS prod /asc through fleet-secrets + * + * A caller-supplied file is NOT a credential source (Kody rule 42, PR #271): + * the private key is a secret, and the approved sources are the process + * environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain + * credentials. The shell layer that owns the credential reads a staged key + * file and hands the key over in the environment, so ASC_KEY_PATH never + * reaches this client as a path to read. * * Usage: * node asc-api.mjs GET /v1/apps @@ -20,12 +27,13 @@ * echoing anything secret-shaped from the response (ASC responses don't * carry credentials, only app metadata, so this is safe to print as-is). */ -import { existsSync, lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync, chmodSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync, chmodSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { createHash, createSign } from "node:crypto"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { normalizePrivateKey } from "./asc-key.mjs"; // A failure that already knows what to say. // @@ -185,85 +193,27 @@ function fleetSecretsGet(name, folder) { } } -// Expands only a leading "~/" (never "~user/"), so a typo is never merged with -// $HOME โ€” the same rule ship-testflight.sh's expand_tilde applies. -function expandTilde(p) { - if (!p) return ""; - if (p === "~") return homedir(); - return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; -} - // Normalizes a PEM private key and refuses anything that is not one. // -// The check matters because the key can arrive from a file or from an -// environment variable, and both end up in the masked preview that a refused -// request prints. A preview is only safe when the material behind it really -// is a private key. -function normalizePrivateKey(value) { - const pem = String(value || "").replace(/\r\n/g, "\n").trim(); - if (!pem) return null; - if (!/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(pem)) return null; - return pem.endsWith("\n") ? pem : `${pem}\n`; -} - -// The private key may legitimately live in a file: ios-stage-asc-key.sh keeps -// the multiline PEM out of GITHUB_ENV and publishes only a mode-600 path. -// -// That makes the path a credential source, so it gets rules. Without them the -// client reads whatever the path names, treats it as a key, and prints part of -// it into a log line when App Store Connect refuses the credential. +// The check matters because the key arrives in the environment, and it ends up +// in the masked preview that a refused request prints. A preview is only safe +// when the material behind it really is a private key. The parse check lives +// in asc-key.mjs, which the shell layer uses as well, so both sides of the +// handoff accept exactly the same keys. // -// * The file is read with lstat, so a symlink is refused, not followed. -// * It has to be a regular file, small, and a PEM private key, so a path -// that names the wrong file is refused instead of half-read. -// * Only its owner may read it. A private key other users can read is -// already leaked, and the staged key is mode 600. -// -// Returns { pem } or { error }. -function readPrivateKeyFile(keyPath) { - let info; - try { - info = lstatSync(keyPath); - } catch { - return { error: `the key file ${keyPath} could not be read` }; - } - if (!info.isFile()) { - return { error: `the key path ${keyPath} is not a regular file` }; - } - if ((info.mode & 0o077) !== 0) { - const mode = (info.mode & 0o777).toString(8).padStart(3, "0"); - return { error: `the key file ${keyPath} is readable by others (mode ${mode}); a private key has to be owner-only (chmod 600)` }; - } - if (info.size > 64 * 1024) { - return { error: `the key file ${keyPath} is ${info.size} bytes, which is not a private key` }; - } - let raw; - try { - raw = readFileSync(keyPath, "utf8"); - } catch { - return { error: `the key file ${keyPath} could not be read` }; - } - const pem = normalizePrivateKey(raw); - if (!pem) { - return { error: `the key file ${keyPath} does not hold a PEM private key` }; - } - return { pem }; -} - // Returns { keyId, issuerId, privateKeyPem, source } or { error } or { error: null }. // -// The order is the rule: a secret-valued environment variable beats a file, -// and a file beats the network. A path on disk is the weakest source, so it -// must never decide which credential is used when the operator already handed -// the key to the process. +// The order is the rule: the environment beats the network, and a file is not +// a source at all. A path on disk is not an approved secret source, so +// ASC_KEY_PATH is never read here; the shell layer that owns the credential +// reads a staged key and hands it over in the environment. function resolveCredential() { const envKeyId = String(process.env.ASC_KEY_ID || "").trim(); const envIssuer = String(process.env.ASC_ISSUER_ID || "").trim(); - const envKeyPath = expandTilde(String(process.env.ASC_KEY_PATH || "").trim()); const envKeyPem = String(process.env.ASC_KEY_P8 || "").trim(); // 1) The APPLE_API_* trio the ship workflow exports from Infisical. The - // key itself is in the environment, so it wins over every file on disk. + // key itself is in the environment, so it wins over every other source. const keyId = String(process.env.APPLE_API_KEY_ID || "").trim(); const issuerId = String(process.env.APPLE_API_ISSUER_ID || "").trim(); if (keyId && issuerId && process.env.APPLE_API_KEY_P8_BASE64) { @@ -274,26 +224,19 @@ function resolveCredential() { } // 2) The same secret as a plain environment variable. This is the form - // fleet-secrets hands out, so an operator who exported the key gets that - // key used, not whatever ASC_KEY_PATH happens to point at. + // fleet-secrets hands out, and the form the shell layer uses when it + // hands a staged key to this client. if (envKeyId && envIssuer && envKeyPem) { const pem = normalizePrivateKey(envKeyPem); if (!pem) return { error: "ASC_KEY_P8 is set but is not a PEM private key" }; return { keyId: envKeyId, issuerId: envIssuer, privateKeyPem: pem, source: "ASC_KEY_P8 environment" }; } - // 3) The file-backed form. Last, because it is the weakest source, and - // checked, because a path that names the wrong file would put that - // file's content into a log line. - if (envKeyId && envIssuer && envKeyPath) { - const read = readPrivateKeyFile(envKeyPath); - if (read.error) return { error: read.error }; - return { keyId: envKeyId, issuerId: envIssuer, privateKeyPem: read.pem, source: "ASC_KEY_PATH environment" }; - } - - // No file is read here (AFC-359). A file is read only when the operator - // names one in ASC_KEY_PATH, and never a default path. Step 4 reads - // Infisical directly, so a local operator needs nothing exported at all. + // 3) No file is read here (AFC-359, and Kody rule 42 on PR #271). A path on + // disk is not an approved secret source, so ASC_KEY_PATH is not a + // credential source here: the shell layer that owns the credential reads + // a staged key and exports it as ASC_KEY_P8. Step 4 reads Infisical + // directly, so a local operator needs nothing exported at all. // 4) Infisical Fleet-OPS /asc: key id and issuer (unless the environment already // has them) and the PEM as ASC_KEY_P8 when the key id is the /asc one; another @@ -826,8 +769,9 @@ async function main() { "asc-auth: no App Store Connect credential found. Tried, in order:\n" + " 1) APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 in the environment\n" + " 2) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 in the environment\n" + - " 3) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH in the environment\n" + - " 4) Fleet-OPS prod /asc through fleet-secrets" + " 3) Fleet-OPS prod /asc through fleet-secrets\n" + + " A path in ASC_KEY_PATH is not a credential source: the shell layer that owns the\n" + + " credential hands the key over in ASC_KEY_P8" ); process.exit(1); } diff --git a/scripts/ios-fleet/asc-key.mjs b/scripts/ios-fleet/asc-key.mjs new file mode 100644 index 00000000..3d466932 --- /dev/null +++ b/scripts/ios-fleet/asc-key.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * The App Store Connect private key: how it is validated, and how a staged + * key file is read. + * + * WHY THIS IS ITS OWN MODULE (2026-10-21, Kody rule 42 on PR #271): + * The private key is a secret, and the fleet's approved secret sources are the + * process environment, a captured `fleet-secrets get NAME`, and bootstrap-only + * Keychain credentials. A caller-supplied local file is not one of them, so + * the node client never reads a key file. The shell layer that already owns + * the credential reads the staged key and hands it to the client through the + * environment; this module is the part of that handoff the shell needs. + * + * The file read opens the path ONCE with O_NOFOLLOW and inspects and reads the + * descriptor it got, so a path that is swapped for a symlink between the check + * and the read cannot be followed. ENOENT is reported apart from every other + * failure, because a path that names nothing is not a credential and the + * caller falls through to the next source, while a path that names the wrong + * file is a hard stop. + * + * The command line never carries key material: + * read read a staged key file and print the validated key + * validate read a key on stdin and print the validated key + * write read a key on stdin and write it to a mode-600 file + */ +import { closeSync, constants, fstatSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { createPrivateKey } from "node:crypto"; + +// A private key is a few kilobytes. Anything larger is not one. +const MAX_KEY_BYTES = 64 * 1024; + +// Normalizes a PEM private key and refuses anything that is not one. +// +// The envelope alone is not enough: a truncated key, an armored block or a +// mangled export carries the envelope and still fails to parse, and an +// unparsable key that reaches the signer comes back as a raw stack trace +// instead of a credential error. So the key is parsed before it is accepted. +// +// Returns the normalized PEM, or null when the value is not a private key. +export function normalizePrivateKey(value) { + const pem = String(value || "").replace(/\r\n/g, "\n").trim(); + if (!pem) return null; + if (!/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(pem)) return null; + try { + createPrivateKey(pem); + } catch { + return null; + } + return pem.endsWith("\n") ? pem : `${pem}\n`; +} + +// Reads a staged key file. +// +// Returns { pem } when the file holds a usable private key, { missing: true } +// when the path names nothing at all, and { error } for every other problem. +// +// The open, the inspection and the read all use one descriptor, so nothing +// that happens to the path afterwards can change what is read. +export function readPrivateKeyFile(keyPath) { + let fd; + try { + fd = openSync(keyPath, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (err) { + if (err && err.code === "ENOENT") return { missing: true }; + if (err && err.code === "ELOOP") { + return { error: `the key path ${keyPath} is a symbolic link, which is not read` }; + } + return { error: `the key file ${keyPath} could not be read` }; + } + try { + const info = fstatSync(fd); + if (!info.isFile()) { + return { error: `the key path ${keyPath} is not a regular file` }; + } + if ((info.mode & 0o077) !== 0) { + const mode = (info.mode & 0o777).toString(8).padStart(3, "0"); + return { error: `the key file ${keyPath} is readable by others (mode ${mode}); a private key has to be owner-only (chmod 600)` }; + } + if (info.size > MAX_KEY_BYTES) { + return { error: `the key file ${keyPath} is ${info.size} bytes, which is not a private key` }; + } + const pem = normalizePrivateKey(readFileSync(fd, "utf8")); + if (!pem) { + return { error: `the key file ${keyPath} does not hold a PEM private key` }; + } + return { pem }; + } finally { + closeSync(fd); + } +} + +// Writes a key to a file only its owner can read. +// +// The key arrives on stdin, so it never becomes a command argument, and the +// file is created with mode 600 whatever the umask of the caller says. +export function writePrivateKeyFile(keyPath, value) { + const pem = normalizePrivateKey(value); + if (!pem) return { error: "the value is not a PEM private key" }; + try { + writeFileSync(keyPath, pem, { mode: 0o600 }); + } catch { + return { error: `the key file ${keyPath} could not be written` }; + } + return { pem }; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +// The exit codes the shell layer reads: +// 0 the key was read +// 1 the path names something that is not a usable key +// 3 the path names nothing at all +export const EXIT_MISSING = 3; + +async function main() { + const [command, target] = process.argv.slice(2); + if (command === "read") { + if (!target) { + console.error("asc-key: usage: asc-key.mjs read "); + return 1; + } + const read = readPrivateKeyFile(target); + if (read.missing) { + console.error(`asc-key: the key file ${target} does not exist`); + return EXIT_MISSING; + } + if (read.error) { + console.error(`asc-key: ${read.error}`); + return 1; + } + process.stdout.write(read.pem); + return 0; + } + if (command === "validate") { + const pem = normalizePrivateKey(await readStdin()); + if (!pem) { + console.error("asc-key: the value is not a PEM private key"); + return 1; + } + process.stdout.write(pem); + return 0; + } + if (command === "write") { + if (!target) { + console.error("asc-key: usage: asc-key.mjs write "); + return 1; + } + const written = writePrivateKeyFile(target, await readStdin()); + if (written.error) { + console.error(`asc-key: ${written.error}`); + return 1; + } + return 0; + } + console.error("asc-key: usage: asc-key.mjs read | validate | write "); + return 1; +} + +if (process.argv[1] && process.argv[1].endsWith("asc-key.mjs")) { + main().then((code) => { process.exitCode = code; }); +} diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 5132cd33..c00ec179 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -84,6 +84,9 @@ # ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (env first, e.g. CI; else Infisical via # `fleet-secrets get` from Fleet-OPS /asc: ASC_KEY_ID, ASC_ISSUER_ID and the .p8 in ASC_KEY_P8, # written to a 0600 temp file and deleted on exit) +# A named ASC_KEY_PATH is read here, never by the node client: the key is +# handed to the client in ASC_KEY_P8, because a path on disk is not an +# approved secret source. # or Xcode-signed-in session for destination=upload export # SENTRY_AUTH_TOKEN (dSYM + Size Analysis upload; env or `fleet-secrets get`) # SENTRY_DSN (injected into xcodebuild so Cocoa plist $(SENTRY_DSN) is set) @@ -204,70 +207,117 @@ fs_get() { # Load ASC credentials into the CURRENT shell (must not run inside $(...) or the # exported ASC_* vars are discarded with the subshell). Sets AUTH_MODE to -# "api_key" or "none". Never prints secret values. Order: an explicit -# ASC_KEY_PATH/ASC_KEY_ID/ASC_ISSUER_ID in the environment, else Infisical. +# "api_key" or "none". Never prints secret values. +# +# WHY THE KEY IS HANDED OVER IN THE ENVIRONMENT (2026-10-21, Kody rule 42): +# The private key is a secret, and the approved secret sources are the process +# environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain +# credentials. A caller-supplied file is not one of them, so the node client +# never reads a key file. This shell layer already owns the credential for +# altool and Xcode, so it reads the staged key here and exports it as +# ASC_KEY_P8, which is where the client looks for it. The hosted runner stages +# the key to a mode-600 file and publishes only its path, so this is the route +# the CI job takes as well. +# +# The order is deliberate: +# 1. ASC_KEY_PATH, when the operator named one and it holds a usable key +# 2. ASC_KEY_P8, when it holds a PEM private key and comes with its key id +# 3. Infisical Fleet-OPS /asc (or /authkey--p8) +# An ambient ASC_KEY_P8 never replaces a named key file: a stale or foreign +# value in an operator's shell would sign the upload with the wrong key, and +# App Store Connect would answer 401 without saying which input was wrong. load_secrets() { AUTH_MODE="none" local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower - # A secret-valued environment variable beats a path on disk. ASC_KEY_P8 is - # what fleet-secrets hands out, so an operator who exported the key gets - # that key used; a leftover ASC_KEY_PATH must not decide which credential - # signs the upload. The hosted runner never exports ASC_KEY_P8 (the - # multiline PEM stays out of GITHUB_ENV), so this branch is the local - # operator's path and leaves the runner's behaviour untouched. - if [[ -n "$kid" && -n "$iss" && -n "${ASC_KEY_P8:-}" ]]; then - if [[ -z "$ASC_TMP_DIR" ]]; then - ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" + local key_pem="" key_path="" rc=0 + + # 1. A key file the operator named. It is opened once with O_NOFOLLOW and + # read from that descriptor, so a path that is swapped for a symlink + # cannot be followed. A path that names nothing falls through to the + # fleet credential instead of aborting the run. + if [[ -n "$kid" && -n "$iss" && -n "${ASC_KEY_PATH:-}" ]]; then + local expanded + expanded="$(expand_tilde "$ASC_KEY_PATH")" + rc=0 + key_pem="$(node "${FLEET_DIR}/asc-key.mjs" read "$expanded")" || rc=$? + if [[ "$rc" -eq 0 ]]; then + key_path="$expanded" + else + if [[ "$rc" -eq 3 ]]; then + log "ASC_KEY_PATH names a file that does not exist; falling back to the fleet credential" + else + log "ASC_KEY_PATH does not hold a usable private key; falling back to the fleet credential" + fi + key_pem="" fi - ( umask 077; printf '%s\n' "$ASC_KEY_P8" > "${ASC_TMP_DIR}/AuthKey_${kid}.p8" ) - chmod 600 "${ASC_TMP_DIR}/AuthKey_${kid}.p8" - export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="${ASC_TMP_DIR}/AuthKey_${kid}.p8" - export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" - ASC_KEY_P8="" - AUTH_MODE="api_key" - return 0 fi - if [[ -n "${ASC_KEY_ID:-}" && -n "${ASC_ISSUER_ID:-}" && -n "${ASC_KEY_PATH:-}" ]]; then - if [[ -f "$(expand_tilde "$ASC_KEY_PATH")" ]]; then - AUTH_MODE="api_key" - return 0 + + # 2. An ambient ASC_KEY_P8. It is only usable when it really is a PEM + # private key and comes with the key id and issuer it belongs to. + if [[ -z "$key_pem" && -n "${ASC_KEY_P8:-}" ]]; then + if [[ -z "$kid" || -z "$iss" ]]; then + log "ASC_KEY_P8 is set without ASC_KEY_ID and ASC_ISSUER_ID; ignoring it" + unset ASC_KEY_P8 + else + rc=0 + key_pem="$(node "${FLEET_DIR}/asc-key.mjs" validate 2>/dev/null <<< "$ASC_KEY_P8")" || rc=$? + if [[ "$rc" -ne 0 ]]; then + log "ASC_KEY_P8 is set but does not hold a PEM private key; ignoring it" + unset ASC_KEY_P8 + key_pem="" + fi fi - log "ASC_KEY_PATH set but file missing (not printing path contents)" fi - # The key id, issuer and private key must belong together: with nothing named in the + + # 3. The key id, issuer and private key must belong together: with nothing named in the # environment all three come from /asc; an env key id that is not the /asc one gets its # own .p8 from /authkey--p8, never the /asc key. - fkid="$(fs_get ASC_KEY_ID /asc)" || fkid="" - if [[ -z "$kid" ]]; then - kid="$fkid" - iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" - elif [[ -z "$iss" && "$kid" == "$fkid" ]]; then - # env named the /asc key itself: its issuer is the /asc one - iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" - fi - if [[ -z "$kid" || -z "$iss" ]]; then - log "ASC credentials missing: set ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_PATH or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc" - return 0 - fi - if [[ "$kid" == "$fkid" ]]; then - p8="$(fs_get ASC_KEY_P8 /asc)" || p8="" - else - lower="$(printf '%s' "$kid" | tr 'A-Z' 'a-z')" - p8="$(fs_get "AUTHKEY_${kid}" "/authkey-${lower}-p8")" || p8="" - fi - if [[ -z "$p8" ]]; then - log "missing the .p8 for key ${kid}: Infisical /asc ASC_KEY_P8 (the /asc key) or /authkey--p8 AUTHKEY_" - return 0 + if [[ -z "$key_pem" ]]; then + fkid="$(fs_get ASC_KEY_ID /asc)" || fkid="" + if [[ -z "$kid" ]]; then + kid="$fkid" + iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" + elif [[ -z "$iss" && "$kid" == "$fkid" ]]; then + # env named the /asc key itself: its issuer is the /asc one + iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" + fi + if [[ -z "$kid" || -z "$iss" ]]; then + log "ASC credentials missing: set ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_PATH or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc" + return 0 + fi + if [[ "$kid" == "$fkid" ]]; then + p8="$(fs_get ASC_KEY_P8 /asc)" || p8="" + else + lower="$(printf '%s' "$kid" | tr 'A-Z' 'a-z')" + p8="$(fs_get "AUTHKEY_${kid}" "/authkey-${lower}-p8")" || p8="" + fi + if [[ -z "$p8" ]]; then + log "missing the .p8 for key ${kid}: Infisical /asc ASC_KEY_P8 (the /asc key) or /authkey--p8 AUTHKEY_" + return 0 + fi + key_pem="$p8" + p8="" fi - if [[ -z "$ASC_TMP_DIR" ]]; then - ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" + + # The key has to be in a mode-600 file for altool and Xcode, and in the + # environment for the node client, which never reads a file. + if [[ -z "$key_path" ]]; then + if [[ -z "$ASC_TMP_DIR" ]]; then + ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" + fi + key_path="${ASC_TMP_DIR}/AuthKey_${kid}.p8" + if ! node "${FLEET_DIR}/asc-key.mjs" write "$key_path" <<< "$key_pem"; then + log "could not write the ASC key file; the credential is not usable" + return 0 + fi + # altool searches API_PRIVATE_KEYS_DIR first, so no symlink into ~/.appstoreconnect is needed. + export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" fi - ( umask 077; printf '%s\n' "$p8" > "${ASC_TMP_DIR}/AuthKey_${kid}.p8" ) - chmod 600 "${ASC_TMP_DIR}/AuthKey_${kid}.p8" - p8="" - export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="${ASC_TMP_DIR}/AuthKey_${kid}.p8" - # altool searches API_PRIVATE_KEYS_DIR first, so no symlink into ~/.appstoreconnect is needed. - export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" + export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="$key_path" + # The client takes the key from the environment: a path is not an approved + # secret source, so it never reads one. + export ASC_KEY_P8="$key_pem" + key_pem="" AUTH_MODE="api_key" return 0 } diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs index 60986266..73b65247 100644 --- a/scripts/ios-fleet/test-asc-client.mjs +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -11,7 +11,7 @@ // port, with a synthetic key. No network, no fleet-secrets, no real secret. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { generateKeyPairSync } from "node:crypto"; import { tmpdir } from "node:os"; @@ -24,11 +24,11 @@ const script = join(here, "asc-api.mjs"); const root = mkdtempSync(join(tmpdir(), "asc-client-test-")); const keyPath = join(root, "synthetic.p8"); -writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey - .export({ type: "pkcs8", format: "pem" })); -// A private key only its owner may read, which is what the client now -// requires of a file it is pointed at. The staged key that -// ios-stage-asc-key.sh publishes is mode 600 for the same reason. +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); +writeFileSync(keyPath, keyPem); +// The staged key that ios-stage-asc-key.sh publishes is mode 600, and the +// shell layer refuses a key file other users can read. chmodSync(keyPath, 0o600); const PROD = "https://api.appstoreconnect.apple.com"; @@ -40,11 +40,14 @@ function run(args, env = {}) { ...process.env, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: keyPath, - // The credential sources are cleared so an ambient ASC_KEY_P8 or + // The client takes the key from the environment: a path on disk is + // not an approved secret source (Kody rule 42), so the shell layer + // hands the key over in ASC_KEY_P8. + ASC_KEY_P8: keyPem, + // The credential sources are cleared so an ambient ASC_KEY_PATH or // APPLE_API_* on the machine running the suite cannot decide which // credential a test gets. - ASC_KEY_P8: "", + ASC_KEY_PATH: "", APPLE_API_KEY_ID: "", APPLE_API_ISSUER_ID: "", APPLE_API_KEY_P8_BASE64: "", @@ -199,7 +202,7 @@ test("a rejected key is reported as a rejected key, not as a missing app", async // any of it being published. It is taken from the key material rather // than from the PEM envelope: the envelope is the same for every key, so // a preview of it names none of them. - const pem = readFileSync(keyPath, "utf8"); + const pem = keyPem; const material = pem.replace(/-----[^-]+-----/g, "").trim(); const expectedPreview = `${material.slice(0, 8)}โ€ฆ${material.slice(-4)}`; assert.ok( @@ -228,9 +231,10 @@ test("two different keys do not get the same masked preview", async () => { // from the same file in the test above cannot catch a preview that is // identical for every key, so the two keys have to be compared with each // other. + const otherKeyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); const otherKeyPath = join(root, "other-synthetic.p8"); - writeFileSync(otherKeyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey - .export({ type: "pkcs8", format: "pem" })); + writeFileSync(otherKeyPath, otherKeyPem); chmodSync(otherKeyPath, 0o600); const server = createServer((req, res) => { @@ -241,10 +245,10 @@ test("two different keys do not get the same masked preview", async () => { const base = `http://127.0.0.1:${server.address().port}`; const previews = []; try { - for (const path of [keyPath, otherKeyPath]) { + for (const pem of [keyPem, otherKeyPem]) { const result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { ASC_API_BASE: base, - ASC_KEY_PATH: path + ASC_KEY_P8: pem }); const diagnostic = result.stderr.split("\n").find((line) => line.startsWith("latest-build-seq:")); const preview = diagnostic && diagnostic.match(/key in play (\S+)/); @@ -286,13 +290,12 @@ test("a large payload is not truncated on the way out", async () => { ); }); -test("a secret in the environment wins over a key file", async () => { - // Two credential sources are present: the key the operator exported and a - // path left over from an earlier step. The secret has to be the one used, - // because a file is the weakest source and the path must never decide which - // credential signs the request. - const secretPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey - .export({ type: "pkcs8", format: "pem" }); +test("a named key file is not a credential source", async () => { + // A path on disk is not an approved secret source, so the client never + // reads one: the shell layer that owns the credential reads the staged + // key and hands it over in the environment. The file below holds a real + // private key, so a client that still read paths would sign the request + // with it and name the file as the credential in play. const server = createServer((req, res) => { res.writeHead(401, { "Content-Type": "text/plain" }); res.end("unauthorized"); @@ -303,8 +306,7 @@ test("a secret in the environment wins over a key file", async () => { try { result = await run(["GET", "/v1/apps"], { ASC_API_BASE: base, - ASC_KEY_P8: secretPem, - ASC_KEY_PATH: join(root, "never-read.p8") + ASC_KEY_PATH: keyPath }); } finally { server.close(); @@ -312,16 +314,16 @@ test("a secret in the environment wins over a key file", async () => { assert.match( result.stderr, /asc-auth: using ASC_KEY_P8 environment/, - `the exported secret was not the credential in play:\n${result.stderr}` + `the environment was not the credential in play:\n${result.stderr}` ); assert.doesNotMatch(result.stderr, /using ASC_KEY_PATH environment/); }); -test("a key file that is not a private key is refused, and its content stays out of the log", async () => { - // The path names a real file, so the client used to read it, treat it as - // key material, and print a masked preview of it when App Store Connect - // refused the credential. A path that names the wrong file has to be - // refused instead, and the file's content must never reach a log line. +test("a path that names the wrong file is not read, and its content stays out of the log", async () => { + // The path names a real file. A client that read paths would treat it as + // key material and print a masked preview of it when App Store Connect + // refused the credential, so the file's content must never reach a log + // line and the request has to be signed with the environment credential. const notAKey = join(root, "not-a-key.txt"); writeFileSync(notAKey, "hunter2 this is not a private key\n"); chmodSync(notAKey, 0o600); @@ -339,30 +341,38 @@ test("a key file that is not a private key is refused, and its content stays out } finally { server.close(); } - assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); - assert.match(result.stderr, /does not hold a PEM private key/); - assert.doesNotMatch(result.stderr, /hunter2/, "the refused file's content reached the log"); - assert.equal(requests, 0, "a refused credential must not reach App Store Connect"); + assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/); + assert.doesNotMatch(result.stderr, /hunter2/, "the named file's content reached the log"); + assert.equal(requests, 1, "the request has to be signed with the environment credential"); }); -test("a key file other users can read is refused", async () => { - // A private key another user can read is already leaked, so the client - // must not treat it as usable key material in the first place. - const loose = join(root, "loose.p8"); - writeFileSync(loose, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey - .export({ type: "pkcs8", format: "pem" })); - chmodSync(loose, 0o644); - const result = await run(["GET", "/v1/apps"], { ASC_KEY_PATH: loose }); +test("a malformed PEM in the environment is a credential error, not a stack trace", async () => { + // An envelope is not a key: a truncated key, an armored block or a + // mangled export carries the envelope and still fails to parse. The + // refusal has to name the credential, because the signer would fail with a + // stack trace that says nothing about what to change. + const malformed = "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"; + const result = await run(["GET", "/v1/apps"], { ASC_KEY_P8: malformed }); assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); - assert.match(result.stderr, /readable by others/); + assert.match(result.stderr, /ASC_KEY_P8 is set but is not a PEM private key/); + assert.ok( + !/at Object\.|at Module\.|at file:/.test(result.stderr), + `the refusal came with a stack trace:\n${result.stderr}` + ); }); -test("a symlinked key path is refused rather than followed", async () => { +// The two refusals below belong to the staged key file, which the shell +// layer reads, not to the client. They are covered in test-asc-key.test.mjs: +// a file other users can read is refused, and a symlink is not followed. + +test("a symlinked key path is never followed", async () => { // A symlink is followed by every ordinary file read, so it would let a - // path that looks like a key file resolve to something else entirely. + // path that looks like a key file resolve to something else entirely. The + // client reads no path at all, and the staged key reader that does read + // files refuses a symlink as well (test-asc-key.test.mjs). const link = join(root, "linked.p8"); symlinkSync(keyPath, link); const result = await run(["GET", "/v1/apps"], { ASC_KEY_PATH: link }); - assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); - assert.match(result.stderr, /is not a regular file/); + assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/); + assert.doesNotMatch(result.stderr, /is not a regular file/); }); \ No newline at end of file diff --git a/scripts/ios-fleet/test-asc-credentials.mjs b/scripts/ios-fleet/test-asc-credentials.mjs index 4696dcb4..e6ce4969 100644 --- a/scripts/ios-fleet/test-asc-credentials.mjs +++ b/scripts/ios-fleet/test-asc-credentials.mjs @@ -12,6 +12,11 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +// The staged key reader has its own test file. The import is what keeps +// those tests in CI: the contract workflow runs this file, and it cannot +// know about a new one. +import "./test-asc-key.test.mjs"; + const here = dirname(fileURLToPath(import.meta.url)); const script = join(here, "asc-api.mjs"); const pem = () => generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey @@ -99,21 +104,19 @@ test("an env key id with no key material anywhere fails closed", async () => { assert.equal(r.kid, null); }); -test("a complete environment (id, issuer, key file) wins and never calls fleet-secrets", async () => { - const keyFile = join(root, "env-key.p8"); - writeFileSync(keyFile, pem()); - chmodSync(keyFile, 0o600); - const r = await run({ ASC_KEY_ID: "ENVKEY0001", ASC_ISSUER_ID: "env-issuer", ASC_KEY_PATH: keyFile, FLEET_SECRETS: join(root, "does-not-exist") }); +test("a complete environment (id, issuer, key) wins and never calls fleet-secrets", async () => { + const r = await run({ ASC_KEY_ID: "ENVKEY0001", ASC_ISSUER_ID: "env-issuer", ASC_KEY_P8: pem(), FLEET_SECRETS: join(root, "does-not-exist") }); assert.equal(r.code, 0); assert.equal(r.kid, "ENVKEY0001"); assert.equal(r.iss, "env-issuer"); }); -test("an exported secret wins over a key file, so an unusable path cannot break the run", async () => { - // The path names a file that is not a private key, so it would be refused - // if it were consulted. The secret has to be used instead, because a - // path on disk is the weakest source and must never decide which - // credential signs the request. +test("a named key file is not a credential source", async () => { + // A path on disk is not an approved secret source, so the client never + // reads one: the shell layer that owns the credential reads the staged + // key and hands it over in the environment. The path below names a file + // that is not a private key, so a client that still consulted it would + // refuse the credential instead of signing the request. const notAKey = join(root, "not-a-key.p8"); writeFileSync(notAKey, "this is not a private key\n"); chmodSync(notAKey, 0o600); @@ -127,16 +130,16 @@ test("an exported secret wins over a key file, so an unusable path cannot break assert.equal(r.kid, "ENVKEY0002"); }); -test("only a leading ~/ is expanded in ASC_KEY_PATH", async () => { - const tildeKey = join(home, "tilde-key.p8"); - writeFileSync(tildeKey, pem()); - chmodSync(tildeKey, 0o600); - const gone = join(root, "does-not-exist"); - const ok = await run({ ASC_KEY_ID: "TILDEKEY01", ASC_ISSUER_ID: "tilde-issuer", ASC_KEY_PATH: "~/tilde-key.p8", FLEET_SECRETS: gone }); - assert.equal(ok.code, 0); - assert.equal(ok.kid, "TILDEKEY01"); - const bad = await run({ ASC_KEY_ID: "TILDEKEY01", ASC_ISSUER_ID: "tilde-issuer", ASC_KEY_PATH: "~other/tilde-key.p8", FLEET_SECRETS: gone }); - assert.notEqual(bad.code, 0); +test("a malformed PEM in the environment fails closed, before any request", async () => { + // The envelope is not a key, so the credential has to be refused with a + // credential error rather than reaching the signer. + const r = await run({ + ASC_KEY_ID: "ENVKEY0003", + ASC_ISSUER_ID: "env-issuer", + ASC_KEY_P8: "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n" + }); + assert.notEqual(r.code, 0); + assert.equal(r.kid, null); }); test.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index 84ba485c..d1398497 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -1,20 +1,38 @@ #!/usr/bin/env bash -# AFC-359: ship-testflight.sh resolves App Store Connect credentials from the environment -# first, then from Infisical through fleet-secrets, and never from the old secrets folder. -# This test extracts fs_get/load_secrets/cleanup_asc_tmp from the script and runs them -# against a FAKE fleet-secrets binary under a throwaway HOME. No network, no real secrets. +# AFC-359: ship-testflight.sh resolves App Store Connect credentials from the +# environment first, then from Infisical through fleet-secrets, and never from +# the old secrets folder. +# +# 2026-10-21 (Kody rule 42 on PR #271): the private key is handed over in the +# environment, never as a path the node client reads. The shell layer owns the +# credential, so this test drives load_secrets with a FAKE fleet-secrets binary +# and throwaway keys generated here at test time. No network, no real +# secrets, and no committed key. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script="${here}/ship-testflight.sh" tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT +# The throwaway HOME lives inside the temp dir, so the cleanup has to run with +# the real HOME in place: a trash helper that protects the home directory +# refuses to remove a parent of it. +real_home="${HOME}" +trap 'HOME="$real_home" rm -rf "$tmp"' EXIT export HOME="${tmp}/home" export TMPDIR="${tmp}/tmp" mkdir -p "$HOME" "$TMPDIR" fail() { echo "FAIL: $*" >&2; exit 1; } +# A throwaway key pair, generated here and never committed. The client parses +# a PEM before it accepts it, so a fake envelope is not a key. +gen_pem() { + node -e 'process.stdout.write(require("crypto").generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ type: "pkcs8", format: "pem" }))' +} +gen_pem > "${tmp}/asc.p8" +gen_pem > "${tmp}/other.p8" +chmod 600 "${tmp}/asc.p8" "${tmp}/other.p8" + # Fake fleet-secrets: prints synthetic values, records the folders it was asked for. cat > "${tmp}/fleet-secrets" <<'FAKE' #!/usr/bin/env bash @@ -24,13 +42,15 @@ echo "${name} ${folder}" >> "${FAKE_LOG}" case "${folder}:${name}" in /asc:ASC_KEY_ID) printf 'ABCDE12345\n' ;; /asc:ASC_ISSUER_ID) printf 'issuer-synthetic\n' ;; - /asc:ASC_KEY_P8) printf -- '-----BEGIN PRIVATE KEY-----\nSYNTHETIC\n-----END PRIVATE KEY-----\n' ;; - /authkey-otherkey01-p8:AUTHKEY_OTHERKEY01) printf -- '-----BEGIN PRIVATE KEY-----\nOTHERKEYMATERIAL\n-----END PRIVATE KEY-----\n' ;; + /asc:ASC_KEY_P8) cat "${FAKE_ASC_PEM}" ;; + /authkey-otherkey01-p8:AUTHKEY_OTHERKEY01) cat "${FAKE_OTHER_PEM}" ;; *) exit 3 ;; esac FAKE chmod +x "${tmp}/fleet-secrets" export FAKE_LOG="${tmp}/fake.log" +export FAKE_ASC_PEM="${tmp}/asc.p8" +export FAKE_OTHER_PEM="${tmp}/other.p8" : > "$FAKE_LOG" extract() { @@ -40,18 +60,20 @@ extract() { grab && /^\}/ { grab = 0 } ' "$script" } + funcs="${tmp}/funcs.sh" { echo 'log() { :; }' echo 'ASC_TMP_DIR=""' echo "FLEET_SECRETS_BIN=\"${tmp}/fleet-secrets\"" + echo "FLEET_DIR=\"${here}\"" extract } > "$funcs" grep -q '^load_secrets()' "$funcs" || fail "load_secrets not found in ship-testflight.sh" grep -q '^fs_get()' "$funcs" || fail "fs_get not found in ship-testflight.sh" # 1. Nothing in the environment: credentials come from Infisical, the key is a 0600 temp file. -out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH bash -c ' +out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH -u ASC_KEY_P8 bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets @@ -61,7 +83,10 @@ out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH bash -c ' mode="$(stat -c %a "$ASC_KEY_PATH" 2>/dev/null || stat -f %Lp "$ASC_KEY_PATH")" [[ "$mode" == 600 ]] [[ "$API_PRIVATE_KEYS_DIR" == "$ASC_TMP_DIR" ]] - grep -q SYNTHETIC "$ASC_KEY_PATH" + # The staged file holds the /asc key. + [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/asc.p8")" ]] + # The client takes the key from the environment, never from a path. + [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] echo "$ASC_KEY_PATH" cleanup_asc_tmp [[ ! -e "$ASC_KEY_PATH" ]] @@ -72,60 +97,80 @@ grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "did not ask Infisical /asc for # 2. An explicit ASC_* environment wins and fleet-secrets is not called. : > "$FAKE_LOG" -printf 'synthetic' > "${tmp}/env-key.p8" -env ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer ASC_KEY_PATH="${tmp}/env-key.p8" bash -c ' +cp "${tmp}/asc.p8" "${tmp}/env-key.p8" +chmod 600 "${tmp}/env-key.p8" +env -u ASC_KEY_P8 ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer ASC_KEY_PATH="${tmp}/env-key.p8" bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 && -z "$ASC_TMP_DIR" ]] + # The named file is used where it lies: no copy is made of it. + [[ "$ASC_KEY_PATH" == "'"${tmp}"'/env-key.p8" ]] + # The client still gets the key in the environment. + [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] ' || fail "environment credentials were not honored" [[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" -# 2a. A secret in the environment beats a key file: the operator handed the key to the -# process, so a leftover path must not decide which credential signs the upload. The file -# here is deliberately unusable, so a run that consulted it would fail. +# 2a. A stale ambient ASC_KEY_P8 does not hijack a working ASC_KEY_PATH credential. +# The named file is the credential in play; the stale secret in the environment +# must not replace it, because the upload would then be signed with a key +# App Store Connect does not expect. : > "$FAKE_LOG" -printf 'not a private key\n' > "${tmp}/not-a-key.p8" -chmod 600 "${tmp}/not-a-key.p8" -secret_pem='-----BEGIN PRIVATE KEY----- -SECRETMATERIAL ------END PRIVATE KEY-----' -out="$(env ASC_KEY_ID=ENVKEY0002 ASC_ISSUER_ID=env-issuer ASC_KEY_P8="$secret_pem" \ - ASC_KEY_PATH="${tmp}/not-a-key.p8" bash -c ' +cp "${tmp}/asc.p8" "${tmp}/named-key.p8" +chmod 600 "${tmp}/named-key.p8" +ASC_KEY_ID=ENVKEY0003 ASC_ISSUER_ID=env-issuer \ + ASC_KEY_P8="$(<"${tmp}/other.p8")" ASC_KEY_PATH="${tmp}/named-key.p8" bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets - [[ "$AUTH_MODE" == api_key ]] - [[ "$ASC_KEY_ID" == ENVKEY0002 && "$ASC_ISSUER_ID" == env-issuer ]] - grep -q SECRETMATERIAL "$ASC_KEY_PATH" - mode="$(stat -c %a "$ASC_KEY_PATH" 2>/dev/null || stat -f %Lp "$ASC_KEY_PATH")" - [[ "$mode" == 600 ]] - echo "the exported secret was the key in play" - cleanup_asc_tmp -')" || fail "the exported secret did not win over the key file" -[[ "$out" == *"the exported secret was the key in play"* ]] || fail "the exported secret was not the key in play" + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0003 ]] + [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] + [[ "$(printf "%s" "$ASC_KEY_P8")" != "$(cat "'"$tmp"'/other.p8")" ]] +' || fail "a stale ambient ASC_KEY_P8 hijacked the named key file" [[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" -# 2b. An env key id that is NOT the /asc one never gets the /asc key: its own .p8 comes from +# 2b. An ambient ASC_KEY_P8 that is not a PEM private key is ignored, and the +# run fails closed rather than signing with something that is not a key. +: > "$FAKE_LOG" +ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer ASC_KEY_P8="not a private key at all" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == none && -z "$ASC_TMP_DIR" ]] + [[ -z "${ASC_KEY_P8:-}" ]] +' || fail "an ASC_KEY_P8 that is not a key was adopted" + +# 2c. An ambient ASC_KEY_P8 without a key id is ignored: the key id, the +# issuer and the key belong together. +: > "$FAKE_LOG" +ASC_KEY_ID="" ASC_ISSUER_ID="" ASC_KEY_P8="$(<"${tmp}/other.p8")" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] + [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] +' || fail "an ASC_KEY_P8 without a key id was adopted" + +# 2d. An env key id that is NOT the /asc one never gets the /asc key: its own .p8 comes from # /authkey--p8, and /asc ASC_KEY_P8 is not requested. : > "$FAKE_LOG" -env -u ASC_KEY_PATH ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-other bash -c ' +env -u ASC_KEY_PATH -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-other bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == OTHERKEY01 ]] [[ "$ASC_ISSUER_ID" == issuer-other ]] - grep -q OTHERKEYMATERIAL "$ASC_KEY_PATH" - ! grep -q SYNTHETIC$ "$ASC_KEY_PATH" + [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/other.p8")" ]] + [[ "$(cat "$ASC_KEY_PATH")" != "$(cat "'"$tmp"'/asc.p8")" ]] cleanup_asc_tmp ' || fail "env key id different from /asc was not given its own key" grep -q '^AUTHKEY_OTHERKEY01 /authkey-otherkey01-p8$' "$FAKE_LOG" || fail "did not ask the per-key folder" if grep -q '^ASC_KEY_P8 ' "$FAKE_LOG"; then fail "mixed an env key id with the /asc key"; fi -# 2b2. An env key id that is not the /asc one with NO env issuer fails closed: the /asc issuer +# 2e. An env key id that is not the /asc one with NO env issuer fails closed: the /asc issuer # is never paired with another key's .p8. : > "$FAKE_LOG" -env -u ASC_KEY_PATH -u ASC_ISSUER_ID ASC_KEY_ID=OTHERKEY01 bash -c ' +env -u ASC_KEY_PATH -u ASC_ISSUER_ID -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets @@ -133,29 +178,44 @@ env -u ASC_KEY_PATH -u ASC_ISSUER_ID ASC_KEY_ID=OTHERKEY01 bash -c ' ' || fail "mixed the /asc issuer with another key" if grep -q '^ASC_ISSUER_ID ' "$FAKE_LOG"; then fail "asked /asc for an issuer for a different key id"; fi -# 2c. An env key id equal to the /asc one uses the /asc key. +# 2f. An env key id equal to the /asc one uses the /asc key. : > "$FAKE_LOG" -env -u ASC_KEY_PATH -u ASC_ISSUER_ID ASC_KEY_ID=ABCDE12345 bash -c ' +env -u ASC_KEY_PATH -u ASC_ISSUER_ID -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] - grep -q SYNTHETIC "$ASC_KEY_PATH" + [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/asc.p8")" ]] cleanup_asc_tmp ' || fail "env key id equal to /asc was not served from /asc" grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "did not ask /asc ASC_KEY_P8" -# 2d. An env key id with no key material anywhere fails closed (no api_key mode). +# 2g. An env key id with no key material anywhere fails closed (no api_key mode). : > "$FAKE_LOG" -env -u ASC_KEY_PATH ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer bash -c ' +env -u ASC_KEY_PATH -u ASC_KEY_P8 ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == none && -z "$ASC_TMP_DIR" ]] ' || fail "missing key material did not fail closed" -# 2e. Only a leading "~/" is expanded: "~user/x" must not be merged with $HOME. -env ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' +# 2h. A named ASC_KEY_PATH that does not exist is not a credential: the run +# falls through to the fleet credential instead of aborting, and the path is +# reported as missing rather than as unreadable. +: > "$FAKE_LOG" +err="$(env -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-other ASC_KEY_PATH="${tmp}/no-such-key.p8" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == OTHERKEY01 ]] + [[ "$ASC_KEY_PATH" != "'"${tmp}"'/no-such-key.p8" ]] + [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/other.p8")" ]] + cleanup_asc_tmp +' 2>&1 >/dev/null)" || fail "a missing key file did not fall through to the fleet credential" +[[ "$err" == *"does not exist"* ]] || fail "a missing key file was not reported as missing: ${err}" + +# 2i. Only a leading "~/" is expanded: "~user/x" must not be merged with $HOME. +env -u ASC_KEY_P8 ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' set -euo pipefail source "'"$funcs"'" [[ "$(expand_tilde "~/k.p8")" == "$HOME/k.p8" ]] @@ -163,11 +223,28 @@ env ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' [[ "$(expand_tilde "/abs/k.p8")" == "/abs/k.p8" ]] ' || fail "expand_tilde" -# 3. Nothing is created under the old secrets folder or ~/.appstoreconnect. +# 3. The credential never travels in a command argument. The secret reaches +# the child through the environment, so its own argv must not carry it. +argv="$(ASC_KEY_ID=ENVKEY0005 ASC_ISSUER_ID=env-issuer ASC_KEY_P8="$(<"${tmp}/other.p8")" bash -c ' + set -euo pipefail + ps -o args= -p $$ 2>/dev/null || true +')" +pem="$(<"${tmp}/other.p8")" +[[ "$argv" != *"$pem"* ]] || fail "the credential reached the child in its command arguments" + +# 4. Neither this test nor the ship script passes a credential to `env`, which +# would put it in the process arguments of every child. +for f in "$0" "$script"; do + if grep -nE '(^|[[:space:]])env [^|]*ASC_KEY_P8=' "$f" >/dev/null; then + fail "$(basename "$f") passes a credential through env arguments" + fi +done + +# 5. Nothing is created under the old secrets folder or ~/.appstoreconnect. [[ ! -e "${HOME}/.secrets" ]] || fail "created the old secrets folder" [[ ! -e "${HOME}/.appstoreconnect" ]] || fail "created ~/.appstoreconnect" -# 4. The script text itself no longer names the old folder as a source. +# 6. The script text itself no longer names the old folder as a source. if grep -nE '(~|\$\{?HOME\}?)/\.secrets' "$script" >/dev/null; then fail "ship-testflight.sh still names the old secrets folder" fi diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs new file mode 100644 index 00000000..5c94774a --- /dev/null +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -0,0 +1,174 @@ +// The staged App Store Connect key file: how it is validated, and how it is +// read. +// +// The private key is a secret, and the approved secret sources are the +// process environment, a captured `fleet-secrets get NAME` and bootstrap-only +// Keychain credentials. A caller-supplied file is not one of them, so the +// node client never reads a key file: the shell layer that owns the +// credential reads the staged key and hands it over in the environment. These +// tests cover that reader, because it is the one place a file is read. +// +// Everything here is synthetic: the keys are generated at test time and are +// never committed, and nothing reaches the network. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { normalizePrivateKey, readPrivateKeyFile } from "./asc-key.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const helper = join(here, "asc-key.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-key-test-")); + +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); + +function staged(name, content = keyPem, mode = 0o600) { + const path = join(root, name); + writeFileSync(path, content); + chmodSync(path, mode); + return path; +} + +function run(args, stdin = "") { + return new Promise((resolve) => { + const child = spawn(process.execPath, [helper, ...args], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(stdin); + }); +} + +test("a generated key is accepted and comes back with a trailing newline", () => { + const normalized = normalizePrivateKey(keyPem); + assert.ok(normalized, "a real key was refused"); + assert.ok(normalized.endsWith("\n"), "the key has to end with a newline"); + assert.equal(normalized.trim(), keyPem.trim()); +}); + +test("a malformed PEM is refused, because the envelope is not a key", () => { + // A truncated key, an armored block and a mangled export all carry the + // envelope and still fail to parse. Accepting them would hand the signer + // something it cannot use, and the failure would come back as a stack + // trace instead of a credential error. + assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"), null); + assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n"), null); + assert.equal(normalizePrivateKey(""), null); + assert.equal(normalizePrivateKey(undefined), null); + assert.equal(normalizePrivateKey("hunter2"), null); +}); + +test("a staged key file is read and its key is returned", () => { + const path = staged("good.p8"); + const read = readPrivateKeyFile(path); + assert.ok(read.pem, `the staged key was not read: ${read.error}`); + assert.equal(read.pem.trim(), keyPem.trim()); +}); + +test("a path that names nothing is reported apart from every other failure", () => { + // A path that names nothing is not a credential, so the caller falls + // through to the next source instead of aborting the run. + const read = readPrivateKeyFile(join(root, "no-such-key.p8")); + assert.equal(read.missing, true); + assert.equal(read.error, undefined); +}); + +test("a symlink is refused rather than followed", () => { + // Every ordinary file read follows a symlink, so a path that looks like a + // key file could resolve to something else entirely. The path is opened + // once with O_NOFOLLOW, so the symlink is never followed. + const target = staged("target.p8"); + const link = join(root, "linked.p8"); + symlinkSync(target, link); + const read = readPrivateKeyFile(link); + assert.ok(read.error, "a symlinked key path was read"); + assert.match(read.error, /symbolic link/); +}); + +test("a key file other users can read is refused", () => { + // A private key another user can read is already leaked. + const path = staged("loose.p8", keyPem, 0o644); + const read = readPrivateKeyFile(path); + assert.ok(read.error, "a key file readable by others was accepted"); + assert.match(read.error, /readable by others/); +}); + +test("a file that is not a private key is refused", () => { + const path = staged("not-a-key.p8", "hunter2 this is not a private key\n"); + const read = readPrivateKeyFile(path); + assert.ok(read.error, "a file that is not a key was accepted"); + assert.match(read.error, /does not hold a PEM private key/); +}); + +test("the command line reads a staged key and reports a missing one", async () => { + const path = staged("cli.p8"); + const ok = await run(["read", path]); + assert.equal(ok.code, 0); + assert.equal(ok.stdout.trim(), keyPem.trim()); + + const missing = await run(["read", join(root, "no-such-key.p8")]); + assert.notEqual(missing.code, 0); + assert.match(missing.stderr, /does not exist/); + assert.equal(missing.stdout, "", "a missing key must not put anything on stdout"); +}); + +test("the key never travels in the command arguments", async () => { + // The key goes in on stdin and comes out on stdout, so it is never an + // argument of any process. + const path = staged("argv.p8"); + const result = await run(["validate"], keyPem); + assert.equal(result.code, 0); + assert.equal(result.stdout.trim(), keyPem.trim()); + assert.ok(!result.stderr.includes("PRIVATE KEY"), "the key envelope reached a log line"); +}); + +test("the command line writes a key to a file only its owner can read", async () => { + const path = join(root, "written.p8"); + const result = await run(["write", path], keyPem); + assert.equal(result.code, 0, `the write failed: ${result.stderr}`); + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.equal(readFileSync(path, "utf8").trim(), keyPem.trim()); +}); + +test("the command line refuses a value that is not a key", async () => { + const path = join(root, "refused.p8"); + const result = await run(["write", path], "not a private key"); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /not a PEM private key/); +}); + +test("the staged key reaches the client through the environment", async () => { + // This is the hosted CI path end to end: the runner stages the key to a + // mode-600 file and publishes only its path, the shell layer reads that + // file, and the client takes the key from the environment. The client + // reads no file at all, which is why the path is in the environment here + // and the file is still the credential in play. + const path = staged("ci.p8"); + const read = await run(["read", path]); + assert.equal(read.code, 0, `the staged key was not read: ${read.stderr}`); + const client = spawn(process.execPath, [join(here, "asc-api.mjs"), "GET", "http://127.0.0.1:1/v1/apps"], { + env: { + ...process.env, + ASC_API_BASE: "http://127.0.0.1:1", + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: read.stdout, + ASC_KEY_PATH: path + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stderr = ""; + client.stderr.on("data", (d) => { stderr += d; }); + client.stdout.resume(); + await new Promise((resolve) => client.on("close", resolve)); + assert.match(stderr, /asc-auth: using ASC_KEY_P8 environment/, `the client did not take the key from the environment:\n${stderr}`); + assert.doesNotMatch(stderr, /using ASC_KEY_PATH environment/); +}); diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 8e5e201f..70452d86 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -18,12 +18,11 @@ const here = dirname(fileURLToPath(import.meta.url)); const script = join(here, "asc-api.mjs"); const root = mkdtempSync(join(tmpdir(), "asc-submit-test-")); -const keyPath = join(root, "synthetic.p8"); -writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey - .export({ type: "pkcs8", format: "pem" })); -// A private key only its owner may read, which is what the client now -// requires of a file it is pointed at. -chmodSync(keyPath, 0o600); +// A synthetic EC key. The client takes it from the environment, because a +// path on disk is not an approved secret source: the shell layer that owns +// the credential hands it over in ASC_KEY_P8. +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); const VERSION_ID = "version-1"; const APP_ID = "app-1"; @@ -194,7 +193,7 @@ function submit(base, args = []) { ASC_API_BASE: base, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: keyPath + ASC_KEY_P8: keyPem }, stdio: ["ignore", "pipe", "pipe"] }); diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs index b0a4b63d..56defe8e 100644 --- a/scripts/ios-fleet/test-asc-upload.mjs +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -25,12 +25,11 @@ const root = mkdtempSync(join(tmpdir(), "asc-upload-test-")); // A synthetic EC key: the script signs a JWT with it and the server here never // verifies the signature. -const keyPath = join(root, "synthetic.p8"); -writeFileSync(keyPath, generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey - .export({ type: "pkcs8", format: "pem" })); -// A private key only its owner may read, which is what the client now -// requires of a file it is pointed at. -chmodSync(keyPath, 0o600); +// A synthetic EC key. The client takes it from the environment, because a +// path on disk is not an approved secret source: the shell layer that owns +// the credential hands it over in ASC_KEY_P8. +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); // A stub PNG: the script only reads the IHDR width and height out of bytes // 16..23 to decide which screenshot set the file belongs to, and never looks @@ -173,7 +172,7 @@ function upload(behaviour, file = pngPath) { ASC_API_BASE: apiBase, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: keyPath + ASC_KEY_P8: keyPem }, stdio: ["ignore", "pipe", "pipe"] }); @@ -278,7 +277,7 @@ test("a rejected key is named, never previewed", async () => { ASC_API_BASE: apiBase, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: keyPath + ASC_KEY_P8: keyPem }, stdio: ["ignore", "pipe", "pipe"] }); From 986b6c8241d07bc316c8cc42507d3cafe1798bef Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 09:31:31 -0500 Subject: [PATCH 37/50] fix(ios-ship): hand the staged ASC key to the client through the environment The hardened client in scripts/ios-fleet/asc-api.mjs no longer reads ASC_KEY_PATH, because a path on disk is not an approved secret source under Kody rule 42. Three hosted-runner consumers still handed the client only a path, so merging that work would have left the iOS ship job without a signing key. The standing-testers step now reads the staged key with the hardened reader in scripts/ios-fleet/asc-key.mjs and hands it to the child through the child's environment. The key stays out of argv, out of $GITHUB_ENV and out of the log, and the step still degrades to a warning when no key is staged. The new key test is also wired into the ship contract job, so the fix's own tests actually run in CI. --- .github/workflows/ios-ship-contract.yml | 1 + .github/workflows/ios-ship.yml | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index 174811f0..470093d9 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -48,6 +48,7 @@ jobs: node --test scripts/ios-fleet/test-asc-client.mjs node --test scripts/ios-fleet/test-asc-upload.mjs node --test scripts/ios-fleet/test-asc-submit.mjs + node --test scripts/ios-fleet/test-asc-key.test.mjs python3 scripts/ios-fleet/test_pick_simulator.py python3 scripts/ios-fleet/test_screenshot_ink.py bash scripts/ios-fleet/test-capture-script.sh diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index b3ffe631..93024f5c 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -338,7 +338,15 @@ jobs: exit 0 fi set +e - node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" + # The client resolves the signing key from its environment and never + # from a path (Kody rule 42), so the staged key is read here and + # handed to the child through the child's environment. It stays out + # of argv, out of $GITHUB_ENV and out of the log. + asc_pem="" + if [ -n "${ASC_KEY_PATH:-}" ]; then + asc_pem="$(node scripts/ios-fleet/asc-key.mjs read "$ASC_KEY_PATH" 2>/dev/null)" || asc_pem="" + fi + ASC_KEY_P8="$asc_pem" node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" rc=$? set -e if [ "$rc" -ne 0 ]; then From b9e1b63a3d738f7b68aaf138174287d2a9749f5d Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 09:47:25 -0500 Subject: [PATCH 38/50] CC-169: hand the staged ASC key to the client in the child environment The client has no path source (Kody rule 42), so the two hosted-runner consumers that call it as a child process have to hand it the key themselves. Both read a staged key with the hardened reader and pass it to the child in the child's environment: the node consumer through the env option of execFileSync, the shell consumer through an assignment prefix. The key never reaches argv, a log, the environment file, or a temporary file. A path that names nothing is not a credential: both consumers fall through and the client resolves the credential the way it does today, so a developer who exports ASC_KEY_P8 in their own shell keeps working. The tests drive both consumers against a local server: each one signs its requests with the staged key, the key is in no process's command line, and the environment file never sees it. --- scripts/ios-enable-apns.mjs | 32 +++- scripts/ios-fleet/test-asc-key.test.mjs | 198 ++++++++++++++++++++++- scripts/ios-install-appstore-profiles.sh | 30 +++- 3 files changed, 255 insertions(+), 5 deletions(-) diff --git a/scripts/ios-enable-apns.mjs b/scripts/ios-enable-apns.mjs index bfae13ef..d2258c75 100644 --- a/scripts/ios-enable-apns.mjs +++ b/scripts/ios-enable-apns.mjs @@ -166,14 +166,44 @@ function inspectProductionProfile(encoded) { && xml.includes(`.${bundleIdentifier}`); } +// The App Store Connect client takes the key from the environment, because a +// path on disk is not an approved secret source (Kody rule 42). The hosted +// runner stages the key to a mode-600 file and publishes only its path, so a +// staged key is read here, with the hardened reader, and handed to the child +// in the child's environment. +// +// The key never reaches a command line, a log, the environment file, or a +// temporary file: the reader prints it on stdout, the caller captures it, +// and only the child process receives it. +// +// A path that names nothing is not a credential: the run falls through and +// the client resolves the credential the way it does today, from an exported +// ASC_KEY_P8 or from fleet-secrets. +export function stagedKeyEnvironment(env = process.env) { + const keyPath = env.ASC_KEY_PATH; + if (!keyPath) return {}; + const reader = fileURLToPath(new URL('./ios-fleet/asc-key.mjs', import.meta.url)); + try { + const pem = execFileSync(process.execPath, [reader, 'read', keyPath], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 64 * 1024 + }); + return pem ? { ASC_KEY_P8: pem } : {}; + } catch { + return {}; + } +} + if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const client = fileURLToPath(new URL('./ios-fleet/asc-api.mjs', import.meta.url)); + // The child gets the staged key in its environment, so the credential + // survives the hop without the client ever reading a file. + const childEnv = { ...process.env, ...stagedKeyEnvironment() }; const request = async (method, path, body) => { const args = [client, method, path]; if (body) args.push(JSON.stringify(body)); try { return JSON.parse(execFileSync(process.execPath, args, { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024 + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024, env: childEnv })); } catch (error) { let codes = ''; diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs index 5c94774a..e0ec9e15 100644 --- a/scripts/ios-fleet/test-asc-key.test.mjs +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -11,19 +11,27 @@ // Everything here is synthetic: the keys are generated at test time and are // never committed, and nothing reaches the network. import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { generateKeyPairSync } from "node:crypto"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; import { normalizePrivateKey, readPrivateKeyFile } from "./asc-key.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const helper = join(here, "asc-key.mjs"); +const client = join(here, "asc-api.mjs"); +const repoRoot = join(here, "..", ".."); +const apnsScript = join(repoRoot, "scripts", "ios-enable-apns.mjs"); +const profilesScript = join(repoRoot, "scripts", "ios-install-appstore-profiles.sh"); const root = mkdtempSync(join(tmpdir(), "asc-key-test-")); +// A PATH with node and nothing else: the fleet reader lives in the operator's +// own bin directory, and a test must never reach it or Infisical. +const safePath = [dirname(process.execPath), "/usr/bin", "/bin"].join(":"); const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey .export({ type: "pkcs8", format: "pem" }); @@ -172,3 +180,187 @@ test("the staged key reaches the client through the environment", async () => { assert.match(stderr, /asc-auth: using ASC_KEY_P8 environment/, `the client did not take the key from the environment:\n${stderr}`); assert.doesNotMatch(stderr, /using ASC_KEY_PATH environment/); }); + +// The consumers that call the client as a child process. +// +// The client has no path source (Kody rule 42), so a consumer that runs in the +// hosted runner, where the staged key is published as a path, has to read that +// key and hand it to the child in the child's environment. These tests drive +// the two consumers that do that, and prove the key stays out of argv, out of +// the environment file, and out of every log line. + +function localServer({ holdFirst = 0 } = {}) { + const seen = []; + let requests = 0; + const server = createServer((req, res) => { + seen.push({ url: req.url, authorization: req.headers.authorization || "" }); + requests += 1; + // The first request is held open, so the child process is still alive + // while the process table is read. + const delay = requests === 1 ? holdFirst : 0; + setTimeout(() => { + res.writeHead(200, { "Content-Type": "application/json" }); + if (req.url.startsWith("/v1/bundleIds?")) { + res.end(JSON.stringify({ data: [{ type: "bundleIds", id: "BUNDLE1", + attributes: { identifier: "com.simplewithus.codecaps.ios" } }] })); + return; + } + res.end(JSON.stringify({ data: [] })); + }, delay); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve({ + base: `http://127.0.0.1:${server.address().port}`, + seen, + close: () => server.close() + })); + }); +} + +function spawnNode(args, env) { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +function spawnBash(args, env) { + return new Promise((resolve) => { + const child = spawn("bash", args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +function jwtKeyId(authorization) { + const token = String(authorization || "").replace(/^Bearer /, ""); + if (!token) return null; + return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString()).kid; +} + +test("the staged key is handed to the child in its environment", async () => { + const { stagedKeyEnvironment } = await import(pathToFileURL(apnsScript).href); + const path = staged("handoff.p8"); + const env = stagedKeyEnvironment({ ASC_KEY_PATH: path }); + assert.equal(env.ASC_KEY_P8.trim(), keyPem.trim(), "the staged key was not handed over"); + assert.equal(Object.keys(env).length, 1, "the child environment carries nothing but the key"); + // No staged key means no override, so a developer who exports ASC_KEY_P8 in + // their own shell keeps working. + assert.deepEqual(stagedKeyEnvironment({}), {}); + assert.deepEqual(stagedKeyEnvironment({ ASC_KEY_P8: "ambient" }), {}); + // A path that names nothing is not a credential, so the run falls through + // and the client resolves the credential the way it does today. + assert.deepEqual(stagedKeyEnvironment({ ASC_KEY_PATH: join(root, "no-such-key.p8") }), {}); +}); + +test("the child client reports the ASC_KEY_P8 environment source", async () => { + const { stagedKeyEnvironment } = await import(pathToFileURL(apnsScript).href); + const server = await localServer(); + try { + const result = await spawnNode([client, "GET", `${server.base}/v1/apps`], { + PATH: safePath, + HOME: join(root, "home-client"), + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ...stagedKeyEnvironment({ ASC_KEY_PATH: staged("client.p8") }) + }); + assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/, + `the client did not report the environment source:\n${result.stderr}`); + assert.equal(jwtKeyId(server.seen[0].authorization), "SYNTHETICKEYID1", + "the request was not signed with the staged key"); + } finally { + server.close(); + } +}); + +test("with no staged key and no ambient key the client reports that it has no credential", async () => { + const result = await spawnNode([client, "GET", "http://127.0.0.1:1/v1/apps"], { + // A bare PATH and a throwaway HOME keep the fleet reader out of the way, + // so the client really has no source left. + PATH: "/usr/bin:/bin", + HOME: join(root, "home-empty"), + ASC_API_BASE: "http://127.0.0.1:1", + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer" + }); + assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); + assert.match(result.stderr, /no App Store Connect credential found/); +}); + +test("the APNs consumer signs its requests with the staged key", async () => { + const server = await localServer(); + try { + const home = join(root, "home-apns"); + mkdirSync(home, { recursive: true }); + const result = await spawnNode([apnsScript], { + PATH: safePath, + HOME: home, + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_PATH: staged("apns.p8"), + ASC_KEY_P8: "" + }); + assert.ok(server.seen.length > 0, "the consumer never reached App Store Connect"); + assert.equal(jwtKeyId(server.seen[0].authorization), "SYNTHETICKEYID1", + "the request was not signed with the staged key"); + assert.doesNotMatch(result.stdout + result.stderr, /BEGIN PRIVATE KEY/, + "the key reached the consumer's output"); + } finally { + server.close(); + } +}); + +test("the key never reaches argv and never reaches the environment file", async () => { + const needle = keyPem.split("\n").find((line) => line.length > 32); + const envFile = join(root, "github-env"); + writeFileSync(envFile, ""); + const home = join(root, "home-argv"); + mkdirSync(home, { recursive: true }); + const server = await localServer({ holdFirst: 1500 }); + const env = { + PATH: safePath, + HOME: home, + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_PATH: staged("argv.p8"), + ASC_KEY_P8: "", + GITHUB_ENV: envFile + }; + const runs = [ + spawnNode([apnsScript], env), + spawnBash([profilesScript], env) + ]; + try { + // While the consumers are waiting on App Store Connect, no process may + // carry the key in its command line. + await new Promise((resolve) => setTimeout(resolve, 700)); + const table = execFileSync("ps", ["-A", "-o", "args="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); + assert.ok(!table.includes(needle), "the key reached a command line"); + const results = await Promise.all(runs); + // Both consumers signed their requests with the staged key, so the + // handoff is what put the credential in front of the client. + const signed = server.seen.filter((entry) => jwtKeyId(entry.authorization) === "SYNTHETICKEYID1"); + assert.ok(signed.length >= 2, `only ${signed.length} of the requests carried the staged key`); + assert.ok(server.seen.some((entry) => entry.url.startsWith("/v1/profiles?")), + "the profile consumer never reached App Store Connect"); + assert.ok(server.seen.some((entry) => entry.url.startsWith("/v1/bundleIds?")), + "the APNs consumer never reached App Store Connect"); + for (const result of results) { + assert.doesNotMatch(result.stdout + result.stderr, /BEGIN PRIVATE KEY/, + "the key reached a consumer's output"); + } + assert.ok(!readFileSync(envFile, "utf8").includes(needle), "the key reached the environment file"); + } finally { + server.close(); + } +}); diff --git a/scripts/ios-install-appstore-profiles.sh b/scripts/ios-install-appstore-profiles.sh index e1b26bdb..c7604bfa 100755 --- a/scripts/ios-install-appstore-profiles.sh +++ b/scripts/ios-install-appstore-profiles.sh @@ -8,6 +8,7 @@ umask 077 ROOT="$(cd "$(dirname "$0")/.." && pwd)" MAP="${ROOT}/ios/CodeCapsCompanion/appstore-profiles.json" CLIENT="${ROOT}/scripts/ios-fleet/asc-api.mjs" +KEY_READER="${ROOT}/scripts/ios-fleet/asc-key.mjs" DEST="${HOME}/Library/MobileDevice/Provisioning Profiles" WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/cc-profiles.XXXXXX")" trap 'rm -rf "$WORKDIR"' EXIT @@ -19,7 +20,34 @@ chmod 700 "$DEST" exit 1 } -node "$CLIENT" GET '/v1/profiles?filter[profileType]=IOS_APP_STORE&limit=200&include=bundleId' \ +# The client takes the key from the environment, because a path on disk is not +# an approved secret source (Kody rule 42). The hosted runner stages the key +# to a mode-600 file and publishes only its path, so the staged key is read +# here, with the hardened reader, and handed to the child in the child's +# environment. The key never reaches a command line, a log, the environment +# file, or a temporary file. +# +# A path that names nothing is not a credential: the run falls through and the +# client resolves the credential the way it does today, from an exported +# ASC_KEY_P8 or from fleet-secrets. +staged_key="" +if [[ -n "${ASC_KEY_PATH:-}" ]]; then + read_rc=0 + staged_key="$(node "$KEY_READER" read "$ASC_KEY_PATH")" || read_rc=$? + if [[ "$read_rc" -ne 0 ]]; then + staged_key="" + fi +fi + +run_client() { + if [[ -n "$staged_key" ]]; then + ASC_KEY_P8="$staged_key" node "$CLIENT" "$@" + else + node "$CLIENT" "$@" + fi +} + +run_client GET '/v1/profiles?filter[profileType]=IOS_APP_STORE&limit=200&include=bundleId' \ >"$WORKDIR/profiles.json" python3 - "$MAP" "$DEST" "$WORKDIR/profiles.json" <<'PY' import base64, json, os, plistlib, subprocess, sys From 8edd853dd2f7d8cf5f451e9188651d4537686be5 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:47:58 -0500 Subject: [PATCH 39/50] CC-169: bind the version to a review submission in submit-review The command could never work from a clean account, and the API's error does not say why. A version is attached to a review submission by a review submission item, a resource of its own. The items relationship on the submission allows GET_RELATED and GET_RELATIONSHIP only, so it can be read but never written, and a version that no item names is bound to nothing. The command now makes that binding itself, with POST /v1/reviewSubmissionItems pointing at both the submission and the version, and only then sends the one call that cannot be undone. The binding is also how the readiness problems are found. When the version is not yet reviewable, that call is refused with a 409 whose body names every blocker, one entry per resource, under meta.associatedErrors. The command prints that list instead of a bare refusal, so the operator sees the missing screenshot and the unpublished privacy answers by name. Two smaller corrections go with it. The check that a submission already holds this version was reading the item's own id as the version's, which can never match, because the version is named in the item's relationship. And the irreversible call now carries the one attribute that has been proven in production, submitted true, and nothing else: a named submission that carries another platform has its platform set first, on a call that can be undone. A submission that already holds a different version is left alone rather than submitted, because one submission carries one version, and the refusal says which version it holds. A submission bound to nothing is the one that gets this version. An app with no draft submission at all gets one, created with the app relationship and the platform, which is the shape App Store Connect accepts. Tests: scripts/ios-fleet/test-asc-submit.mjs covers the binding, the refusal that names the blockers, the creation of a submission, the platform on a named submission, and the refusal when the submission holds another version. All 65 node tests under scripts/ios-fleet pass, and the shell credential tests pass. --- scripts/ios-fleet/asc-api.mjs | 214 +++++++++++++++++++++++--- scripts/ios-fleet/test-asc-submit.mjs | 183 ++++++++++++++++++++-- 2 files changed, 360 insertions(+), 37 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 84d5447c..954b826b 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -789,7 +789,7 @@ async function main() { console.error(" node asc-api.mjs ensure-standing-testers [buildVersion] (emails from ASC_STANDING_TESTERS)"); console.error(" node asc-api.mjs latest-build-seq [platform] [appleId]"); console.error(" node asc-api.mjs upload-screenshots "); - console.error(" node asc-api.mjs submit-review [reviewSubmissionId]"); + console.error(" node asc-api.mjs submit-review [reviewSubmissionId] [platform]"); // The exit code is set rather than the process ended, so a diagnostic // written to a pipe is flushed instead of dropped. process.exitCode = 1; @@ -861,6 +861,35 @@ async function main() { .join("; "); } + // The blockers App Store Connect names when a version cannot be reviewed. + // + // The refusal on the review submission item carries a `meta.associatedErrors` + // member: a map from the resource that is not ready to the list of state + // errors on it. Each entry carries a code, a title and a detail, and the + // code is the part an operator can act on, so both the resource and the + // code are printed. The shape is checked before anything is read, because + // a refusal is a document from the service and its members are not + // guaranteed. + function describeAssociatedErrors(res) { + const errors = res && res.parsed && Array.isArray(res.parsed.errors) ? res.parsed.errors : []; + const lines = []; + for (const problem of errors) { + if (!isPlainObject(problem)) continue; + const meta = isPlainObject(problem.meta) ? problem.meta : null; + if (!meta || !isPlainObject(meta.associatedErrors)) continue; + for (const [resource, list] of Object.entries(meta.associatedErrors)) { + if (!Array.isArray(list)) continue; + for (const item of list) { + if (!isPlainObject(item)) continue; + const code = typeof item.code === "string" ? item.code : "unknown error"; + const title = typeof item.title === "string" ? item.title : ""; + lines.push(`${resource} -> ${code}${title ? ` (${title})` : ""}`); + } + } + } + return lines; + } + // Guard the shape of a response before any of it is read. // // The scripts in this directory are dependency-free, so the guard is @@ -966,22 +995,29 @@ async function main() { // ------------------------------------------------------------------------- // Submit an app store version for review // ------------------------------------------------------------------------- - // Usage: node asc-api.mjs submit-review [reviewSubmissionId] + // Usage: node asc-api.mjs submit-review [reviewSubmissionId] [platform] // // Submitting is the one call that cannot be undone, so the things that // have to be true first are checked here rather than discovered from // Apple's refusal: // // 1. the version's screenshots are uploaded (a version with no - // screenshots is not submittable, and the review submission's items - // stay empty while that is true) + // screenshots is not submittable, and the binding call refuses with + // the list of what is missing) // 2. a build is attached to the version // 3. the version's versionString matches that build's marketing version // - // The review submission's items cannot be set: the relationship allows - // GET_RELATED and GET_RELATIONSHIP only, and a review submission is - // created with the app relationship, so the version is bound by App Store - // Connect itself when the version becomes submittable. + // The version is attached to the review submission here, because App Store + // Connect never attaches it by itself. The items relationship on the + // submission allows GET_RELATED and GET_RELATIONSHIP only, so it can be + // read but never written: the binding is a resource of its own, a review + // submission item. Without that step the command can never work from a + // clean account, because a fresh version is bound to nothing. + // + // When the version is not yet reviewable, the binding call is refused with + // a 409 whose body names every blocker, one entry per resource. That list + // is the most useful thing this command can print, so it is reported + // rather than swallowed. if (method === "submit-review") { const versionId = path; const submissionId = body; @@ -1147,13 +1183,61 @@ async function main() { return; } - // The review submission. When none is named, the app's own are listed. - // Each candidate is checked to be bound to THIS version before it is - // submitted: a submission can be bound to another version, and - // submitting that one would send the wrong build to review. + // The review submission. + // + // The version is bound to a submission by a review submission item, a + // resource of its own. The items relationship on the submission allows + // GET_RELATED and GET_RELATIONSHIP only, so it can be read but never + // written, and a version that no item names is bound to nothing. So the + // binding is made here, which is what lets this command work from a + // clean account instead of only against a version somebody else prepared. + // + // A submission already bound to a different version is left alone: one + // submission carries one version, and a second one cannot be added to + // it. A submission bound to nothing is the one that gets this version. + const blockers = []; let candidates = []; if (submissionId) { candidates = [submissionId]; + // A named submission is taken as given, but two things about it still + // have to be checked: a submission that was already sent cannot be + // sent again, and its platform has to be the one asked for, because a + // macOS submission for an iOS version would send the wrong build to + // review. The platform is a settable attribute, so it is set here + // rather than left to the one call that cannot be undone. + const named = await api("GET", `/v1/reviewSubmissions/${submissionId}`); + if (!named.ok) { + console.error(`submit-review: ${describeError(named)}`); + process.exitCode = 2; + return; + } + const namedAttributes = isPlainObject(named.parsed?.data?.attributes) + ? named.parsed.data.attributes + : null; + if (!namedAttributes) { + console.error("submit-review: the review submission came back without readable attributes"); + process.exitCode = 2; + return; + } + if (namedAttributes.submittedDate !== null && namedAttributes.submittedDate !== undefined) { + console.error("submit-review: not submitting, the review submission was already submitted"); + process.exitCode = 2; + return; + } + if (namedAttributes.platform !== platform) { + const setPlatform = await api("PATCH", `/v1/reviewSubmissions/${submissionId}`, JSON.stringify({ + data: { + type: "reviewSubmissions", + id: submissionId, + attributes: { platform } + } + })); + if (!setPlatform.ok) { + console.error(`submit-review: the platform could not be set: ${describeError(setPlatform)}`); + process.exitCode = 2; + return; + } + } } else { if (typeof appId !== "string" || appId.length === 0) { console.error("submit-review: not submitting, the version names no app"); @@ -1167,33 +1251,106 @@ async function main() { return; } candidates = requireArray(listed.parsed?.data, "review submission list") - .map((s) => (isPlainObject(s) && typeof s.id === "string" ? s.id : null)) - .filter(Boolean); + .filter((s) => isPlainObject(s) && typeof s.id === "string") + .filter((s) => { + const attributes = isPlainObject(s.attributes) ? s.attributes : {}; + // A submission that was already sent cannot be sent again. + if (attributes.submittedDate !== null && attributes.submittedDate !== undefined) return false; + // A submission for another platform would put the wrong build in + // review, so it is not a candidate. + return typeof attributes.platform !== "string" || attributes.platform === platform; + }) + .map((s) => s.id); } if (candidates.length === 0) { - console.error("submit-review: not submitting, the app has no review submission"); - process.exitCode = 2; - return; + // The app has no draft submission at all, so one is made. The + // platform goes on it at create time, which is the shape App Store + // Connect accepts: items and submitted are both refused on create. + if (typeof appId !== "string" || appId.length === 0) { + console.error("submit-review: not submitting, the version names no app"); + process.exitCode = 2; + return; + } + const created = await api("POST", "/v1/reviewSubmissions", JSON.stringify({ + data: { + type: "reviewSubmissions", + attributes: { platform }, + relationships: { app: { data: { type: "apps", id: appId } } } + } + })); + if (!created.ok) { + console.error(`submit-review: ${describeError(created)}`); + process.exitCode = 2; + return; + } + const createdId = created.parsed?.data?.id; + if (typeof createdId !== "string" || createdId.length === 0) { + console.error("submit-review: the review submission was created but came back without an id"); + process.exitCode = 2; + return; + } + candidates = [createdId]; } + // Is this submission bound to the version? + // + // The item's own id is the review submission item's id, not the + // version's: the version is named in the item's relationship. Reading + // the item's id as the version's would never match, so the check has to + // follow the relationship. + const boundVersionOf = (item) => { + if (!isPlainObject(item)) return null; + const relationships = isPlainObject(item.relationships) ? item.relationships : null; + if (!relationships) return null; + const related = isPlainObject(relationships.appStoreVersion) ? relationships.appStoreVersion : null; + if (!related) return null; + const data = isPlainObject(related.data) ? related.data : null; + return data && typeof data.id === "string" ? data.id : null; + }; + let submitted = null; + const boundElsewhere = []; for (const candidate of candidates) { const items = await api("GET", `/v1/reviewSubmissions/${candidate}/items`); if (!items.ok) { console.error(`submit-review: ${candidate}: ${describeError(items)}`); continue; } - const bound = requireArray(items.parsed?.data, "review submission item list") - .some((item) => isPlainObject(item) && item.id === versionId); - if (!bound) { - console.error(`submit-review: ${candidate} is not bound to ${versionId}; skipping it`); + const list = requireArray(items.parsed?.data, "review submission item list"); + const boundTo = list.map(boundVersionOf).filter(Boolean); + if (boundTo.length > 0 && !boundTo.includes(versionId)) { + boundElsewhere.push(`${candidate} -> ${boundTo.join(", ")}`); + console.error( + `submit-review: ${candidate} is bound to ${boundTo.join(", ")}; skipping it` + ); continue; } + if (boundTo.length === 0) { + // The submission is bound to nothing, so this version goes on it. + const bound = await api("POST", "/v1/reviewSubmissionItems", JSON.stringify({ + data: { + type: "reviewSubmissionItems", + relationships: { + reviewSubmission: { data: { type: "reviewSubmissions", id: candidate } }, + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } + } + } + })); + if (!bound.ok) { + for (const line of describeAssociatedErrors(bound)) blockers.push(line); + console.error(`submit-review: ${candidate} could not be bound to ${versionId}: ${describeError(bound)}`); + continue; + } + console.error(`submit-review: bound ${versionId} to review submission ${candidate}`); + } + // The one call that cannot be undone carries the one attribute that + // has been proven in production, and nothing else. The platform was + // set when the submission was made. const result = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ data: { type: "reviewSubmissions", id: candidate, - attributes: { submitted: true, platform } + attributes: { submitted: true } } })); if (!result.ok) { @@ -1204,7 +1361,18 @@ async function main() { break; } if (!submitted) { - console.error("submit-review: not submitting, no review submission is bound to this version"); + if (blockers.length > 0) { + console.error("submit-review: not submitting, App Store Connect says the version is not ready:"); + for (const line of blockers) console.error(` ${line}`); + } else if (boundElsewhere.length > 0) { + console.error( + "submit-review: not submitting, the app's review submission holds another version " + + `(${boundElsewhere.join("; ")}). One submission carries one version, so it has to be ` + + "cleared in App Store Connect before this one can go to review." + ); + } else { + console.error("submit-review: not submitting, no review submission is bound to this version"); + } process.exitCode = 2; return; } diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 70452d86..1858f998 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -39,10 +39,22 @@ const SUBMISSION_ID = "sub-1"; // preReleaseFails: the build read answers 401 // preReleaseMissing: the build read names no pre-release version, so the // document carries no `included` member -// boundTo: the version the review submission is bound to +// boundTo: the version the review submission's item names; left out, the +// submission has no items at all, which is the state a fresh account is +// in and the one the command has to bind +// bindFails: the binding call answers 409 and names the blockers +// noSubmissions: the app has no review submission at all +// submissionPlatform: the platform the named submission answers with // platform: the platform the submission answers with function serve(options) { const patched = []; + const bound = []; + const created = []; + // A submission that has been marked submitted reads back as one, and a + // draft reads back as a draft. The read before the submit and the read + // after it are the same call, so what changed in between is what tells + // them apart. + const sent = new Set(); const server = createServer((req, res) => { let raw = ""; req.on("data", (chunk) => { raw += chunk; }); @@ -54,7 +66,11 @@ function serve(options) { const url = req.url.split("?")[0]; if (req.method === "PATCH" && url.startsWith("/v1/reviewSubmissions/")) { - patched.push({ url, body: JSON.parse(raw || "{}") }); + const body = JSON.parse(raw || "{}"); + patched.push({ url, body }); + if (body?.data?.attributes?.submitted === true) { + sent.add(body.data.id); + } return json(200, { data: { type: "reviewSubmissions", @@ -155,30 +171,98 @@ function serve(options) { } }); } if (url === `/v1/apps/${APP_ID}/reviewSubmissions`) { + if (options.noSubmissions) { + return json(200, { data: [] }); + } return json(200, { data: [{ id: SUBMISSION_ID, type: "reviewSubmissions", attributes: { platform: "IOS", state: "READY_FOR_REVIEW", submittedDate: null } }] }); } - if (url === `/v1/reviewSubmissions/${SUBMISSION_ID}/items`) { - return json(200, { data: [{ type: "appStoreVersions", id: options.boundTo }] }); + if (req.method === "POST" && url === "/v1/reviewSubmissions") { + created.push(JSON.parse(raw || "{}")); + return json(201, { data: { + type: "reviewSubmissions", + id: "sub-new", + attributes: { platform: "IOS", state: "READY_FOR_REVIEW", submittedDate: null } + } }); } - if (req.method === "GET" && url === `/v1/reviewSubmissions/${SUBMISSION_ID}`) { + if (req.method === "POST" && url === "/v1/reviewSubmissionItems") { + bound.push(JSON.parse(raw || "{}")); + if (options.bindFails) { + // The refusal the live service answers with when the version is + // not ready: the blockers are named, one entry per resource. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state.", + detail: "This resource cannot be reviewed, please check associated errors to see why.", + meta: { + associatedErrors: { + "/v1/appStoreVersionLocalizations/loc-1/placements": [{ + status: "409", + code: "STATE_ERROR.SCREENSHOT_REQUIRED.IPAD_13_PROFILE", + title: "App screenshot missing (IPAD_13_PROFILE).", + detail: "A screenshot for one of the following types is required but was not provided: IPAD_13_PROFILE" + }], + "/v1/appDataUsages/": [{ + status: "409", + code: "STATE_ERROR.APP_DATA_USAGES_REQUIRED", + title: "Answers to what data your app collects and how it's used are needed.", + detail: "You must have published answers to your app's data usages." + }] + } + } + }] }); + } + return json(201, { data: { type: "reviewSubmissionItems", id: "item-1" } }); + } + if (req.method === "GET" && /^\/v1\/reviewSubmissions\/[^/]+\/items$/.test(url)) { + if (options.boundTo === undefined) { + // A submission with no items: the version is bound to nothing. + return json(200, { data: [] }); + } + // The item's own id is the item's id. The version is named in the + // relationship, and reading the item's id as the version's would + // never match. + return json(200, { data: [{ + type: "reviewSubmissionItems", + id: "item-1", + relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: options.boundTo } } } + }] }); + } + if (req.method === "GET" && /^\/v1\/reviewSubmissions\/[^/]+$/.test(url)) { // The read back after the submit, so the report can say what the // submission's state actually is. + const id = url.split("/").pop(); + if (sent.has(id)) { + return json(200, { + data: { + type: "reviewSubmissions", + id, + attributes: { state: "WAITING_FOR_REVIEW", submittedDate: "2026-10-20T00:00:00Z" } + } + }); + } + // The read before the submission is touched: a draft, with the + // platform it carries. return json(200, { data: { type: "reviewSubmissions", - id: SUBMISSION_ID, - attributes: { state: "WAITING_FOR_REVIEW", submittedDate: "2026-10-20T00:00:00Z" } + id, + attributes: { + platform: options.submissionPlatform || "IOS", + state: "READY_FOR_REVIEW", + submittedDate: null + } } }); } return json(404, { errors: [{ title: "not found" }] }); }); }); - return { server, patched }; + return { server, patched, bound, created }; } function submit(base, args = []) { @@ -206,11 +290,11 @@ function submit(base, args = []) { } async function withServer(options, fn) { - const { server, patched } = serve(options); + const { server, patched, bound, created } = serve(options); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const base = `http://127.0.0.1:${server.address().port}`; try { - return await fn(base, patched); + return await fn(base, patched, bound, created); } finally { server.close(); } @@ -260,17 +344,78 @@ test("a screenshot set that could not be read stops the submission", async () => }); test("a review submission bound to another version is not submitted", async () => { + // One submission carries one version, so a submission that already holds + // another version cannot be given this one. It is left alone rather than + // submitted, because submitting it would send the wrong build to review. await withServer({ ...READY, boundTo: "some-other-version" }, async (base, patched) => { const { code, stderr } = await submit(base); assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); assert.ok( - /is not bound to version-1/.test(stderr), + /is bound to some-other-version/.test(stderr), `the refusal did not say the submission was bound elsewhere:\n${stderr}` ); + assert.ok( + /holds another version/.test(stderr), + `the refusal did not say what has to be cleared:\n${stderr}` + ); assert.equal(patched.length, 0, "the wrong version's submission was patched"); }); }); +test("a submission with no items is bound to the version and submitted", async () => { + // This is the state a clean account is in: the version is bound to + // nothing, and the command has to make the binding itself. The items + // relationship cannot be written, so the binding is a review submission + // item of its own. + await withServer({ ...READY, boundTo: undefined }, async (base, patched, bound) => { + const { code, stdout, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(bound.length, 1, "the version was not bound to a review submission"); + const relationships = bound[0].data?.relationships; + assert.equal(relationships?.reviewSubmission?.data?.id, SUBMISSION_ID); + assert.equal(relationships?.appStoreVersion?.data?.id, VERSION_ID); + assert.equal(patched.length, 1, "the submission was not patched"); + assert.equal(patched[0].body?.data?.attributes?.submitted, true); + const report = JSON.parse(stdout.trim()); + assert.equal(report.reviewSubmission, SUBMISSION_ID); + }); +}); + +test("the binding refusal reports the blockers App Store Connect named", async () => { + // The refusal on the binding call carries the list of what is not ready, + // one entry per resource. It is the most useful thing the command can + // print, so it is reported rather than swallowed. + await withServer({ ...READY, boundTo: undefined, bindFails: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /STATE_ERROR\.SCREENSHOT_REQUIRED\.IPAD_13_PROFILE/.test(stderr), + `the refusal did not name the missing screenshot:\n${stderr}` + ); + assert.ok( + /STATE_ERROR\.APP_DATA_USAGES_REQUIRED/.test(stderr), + `the refusal did not name the missing privacy answers:\n${stderr}` + ); + assert.ok( + /\/v1\/appDataUsages\//.test(stderr), + `the refusal did not name the resource that is not ready:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the version was not ready"); + }); +}); + +test("a review submission is created when the app has none", async () => { + await withServer({ ...READY, noSubmissions: true, boundTo: undefined }, async (base, patched, bound, created) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(created.length, 1, "no review submission was created"); + assert.equal(created[0].data?.attributes?.platform, "IOS"); + assert.equal(created[0].data?.relationships?.app?.data?.id, APP_ID); + assert.equal(bound.length, 1, "the version was not bound to the new submission"); + assert.equal(patched.length, 1, "the submission was not patched"); + }); +}); + test("a build whose pre-release version has no include stops the submission", async () => { // The build names a pre-release version and the read returns no include // for it, so the comparison that pairs the version with the build did not @@ -354,7 +499,10 @@ test("a ready version is submitted, and only the report is on stdout", async () assert.equal(patched.length, 1, "the submission was not patched"); const attributes = patched[0].body?.data?.attributes; assert.equal(attributes?.submitted, true, "the submission was not marked submitted"); - assert.equal(attributes?.platform, "IOS", "the platform was not set"); + // The one call that cannot be undone carries the one attribute that has + // been proven in production, and nothing else. The platform was already + // on the submission. + assert.equal(attributes?.platform, undefined, "the irreversible call carried more than the submit"); }); }); @@ -362,11 +510,18 @@ test("the platform can be chosen, and a bad one is refused", async () => { // A macOS version carries desktop screenshots, so it is submitted against // a server that serves them rather than the iPhone and iPad sets. await withServer({ ...READY, iphoneScreens: 0, ipadScreens: 0, desktopScreens: 3 }, async (base, patched) => { - const { code, stdout } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); - assert.equal(code, 0, "the macOS platform was not accepted"); + const { code, stdout, stderr } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); const report = JSON.parse(stdout.trim()); assert.equal(report.platform, "MAC_OS"); + // A named submission that carries another platform has it set first, on + // a call that can be undone, so the irreversible one carries only the + // submit. + assert.equal(patched.length, 2, `expected a platform patch and a submit patch, got ${patched.length}`); assert.equal(patched[0].body?.data?.attributes?.platform, "MAC_OS"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + assert.equal(patched[1].body?.data?.attributes?.submitted, true); + assert.equal(patched[1].body?.data?.attributes?.platform, undefined); }); await withServer(READY, async (base, patched) => { // The iPhone and iPad sets a macOS version does not carry are not From 6ef3baa5b1771f9f07bcf4df37e322a46f676fca Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 10:16:01 -0500 Subject: [PATCH 40/50] CC-169: mark the synthetic key fixtures as scanner-approved The secret scanner reads a key-shaped block when one BEGIN marker and a later END marker sit close enough together in the same file. These fixtures are synthetic and are never a credential, but a squash merge lands the whole file in one commit, so the scanner sees both markers at once and reports the file. The allow marker says so where each fixture is. --- scripts/ios-fleet/test-asc-client.mjs | 2 +- scripts/ios-fleet/test-asc-credentials.mjs | 2 +- scripts/ios-fleet/test-asc-key.test.mjs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs index 73b65247..eed3b677 100644 --- a/scripts/ios-fleet/test-asc-client.mjs +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -351,7 +351,7 @@ test("a malformed PEM in the environment is a credential error, not a stack trac // mangled export carries the envelope and still fails to parse. The // refusal has to name the credential, because the signer would fail with a // stack trace that says nothing about what to change. - const malformed = "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"; + const malformed = "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"; // gitleaks:allow const result = await run(["GET", "/v1/apps"], { ASC_KEY_P8: malformed }); assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); assert.match(result.stderr, /ASC_KEY_P8 is set but is not a PEM private key/); diff --git a/scripts/ios-fleet/test-asc-credentials.mjs b/scripts/ios-fleet/test-asc-credentials.mjs index e6ce4969..25c66ac4 100644 --- a/scripts/ios-fleet/test-asc-credentials.mjs +++ b/scripts/ios-fleet/test-asc-credentials.mjs @@ -136,7 +136,7 @@ test("a malformed PEM in the environment fails closed, before any request", asyn const r = await run({ ASC_KEY_ID: "ENVKEY0003", ASC_ISSUER_ID: "env-issuer", - ASC_KEY_P8: "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n" + ASC_KEY_P8: "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n" // gitleaks:allow }); assert.notEqual(r.code, 0); assert.equal(r.kid, null); diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs index e0ec9e15..4c247b41 100644 --- a/scripts/ios-fleet/test-asc-key.test.mjs +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -67,8 +67,8 @@ test("a malformed PEM is refused, because the envelope is not a key", () => { // envelope and still fail to parse. Accepting them would hand the signer // something it cannot use, and the failure would come back as a stack // trace instead of a credential error. - assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"), null); - assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n"), null); + assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"), null); // gitleaks:allow + assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n"), null); // gitleaks:allow assert.equal(normalizePrivateKey(""), null); assert.equal(normalizePrivateKey(undefined), null); assert.equal(normalizePrivateKey("hunter2"), null); From 868c254c9f9857cb08fa59c5a9e12d694e7da903 Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 10:25:33 -0500 Subject: [PATCH 41/50] CC-169: name the widget bar qualifier from the cadence The widget builds every window label through QuotaWindowVocabulary.windowLabel(...), which rewrites the label into the hyphenated cadence copy ("7-day window"). A qualifier matched from prose words in the label therefore never matched a label the widget renders, so two bars that shared a period both printed an empty qualifier and the owner-approved "weekly" / "additional" pair never appeared. Three Kody findings on the widget bar qualifier are fixed together. The qualifier is now named from the canonical cadence the caption row prints, and the label is consulted for the extra allowance alone. The cadence group resolves the window's own cadence first and falls back to the label only when the cadence field cannot be resolved, because the group decides whether two bars share a period while the caption token beside the bar prints the cadence field. A group that disagreed with the token gave a bar a qualifier naming a period its own token contradicted. An extra allowance is looked up without its label, so it keeps the period it doubles instead of collapsing to "Other limit", and the canonical labels now round-trip through the cadence lookup, so "1-month" resolves like "5-hour" already did. A caller that names a qualifier on a window keeps it. The sibling resolution only fills in a qualifier the caller left blank, so the public parameter no longer disappears on a platform with two or more windows. The tests drive the production label shapes ("7-day window", "5-hour window", "1-month window", "Additional allowance") rather than hand-written prose, so the pair is pinned on the labels the widget actually renders. --- Sources/QuotaCore/QuotaWindowVocabulary.swift | 64 ++++--- .../WidgetPresentationVocabularyTests.swift | 169 +++++++++++++++--- .../Widgets/WidgetPresentation.swift | 7 +- 3 files changed, 186 insertions(+), 54 deletions(-) diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index 48fa25cc..fcef91cc 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -63,43 +63,54 @@ public enum QuotaWindowVocabulary { } /// The short distinguishing noun for a bar caption row, or "" when the - /// label names none. + /// row needs none. /// - /// "Free weekly limit" reads "weekly", "Additional tokens" reads - /// "additional", and a label with no such word reads nothing. - public static func barQualifier(forLabel label: String) -> String { - if isExtraAllowanceLabel(label) { return barQualifierAdditional } - let tokens = lexicalTokens(label) - if tokens.contains("weekly") { return barQualifierWeekly } - if tokens.contains("monthly") || tokens.contains("billing") { return barQualifierMonthly } - if tokens.contains("daily") { return barQualifierDaily } - if tokens.contains("hourly") { return barQualifierHourly } - return "" - } - - /// The qualifier for a bar caption row, or "" when the row needs none. + /// The qualifier is named from the canonical cadence the caption row + /// already prints, not from prose words: the widget's own label pipeline + /// rewrites a label into the hyphenated cadence copy ("7-day window"), so + /// a matcher that looks for "weekly" in the label never matches a label + /// the widget renders (Kody review 2026-10-21). /// /// A cadence word is only worth printing when a sibling bar shares the /// cadence, because on a lone bar "7d weekly" says the same thing twice. - public static func barQualifier(forLabel label: String, sharesCadence: Bool) -> String { + /// + /// The label is consulted for the extra-allowance case alone, which is + /// the one qualifier the label still names. + public static func barQualifier(forCadence cadence: String, label: String, sharesCadence: Bool) -> String { guard sharesCadence else { return "" } - return barQualifier(forLabel: label) + if isExtraAllowanceLabel(label) { return barQualifierAdditional } + switch cadence { + case sevenDay: return barQualifierWeekly + case oneMonth: return barQualifierMonthly + case oneDay: return barQualifierDaily + case fiveHour: return barQualifierHourly + default: return "" + } } /// The canonical cadence a window belongs to, used to group the bars that /// share one period. + /// + /// The window's own cadence wins, because that is what the caption token + /// beside the bar prints; the label is only consulted when the cadence + /// field cannot be resolved on its own (Kody review 2026-10-21). + /// + /// An extra allowance is extra capacity of the period it doubles, so it + /// groups with that window rather than standing alone. It carries no + /// cadence of its own, so its label is left out of the lookup rather + /// than collapsing the row to "Other limit". public static func barCadenceGroup(cadence: String, label: String) -> String { + let canonical = standardCadence( + windowToken: cadence, + label: isExtraAllowanceLabel(label) ? "" : label + ) + if canonical != otherLimit { return canonical } let tokens = lexicalTokens(label) if tokens.contains("weekly") { return sevenDay } if tokens.contains("monthly") || tokens.contains("billing") { return oneMonth } if tokens.contains("daily") { return oneDay } if tokens.contains("hourly") { return fiveHour } - if isExtraAllowanceLabel(label) { - // An extra allowance is extra capacity of the period it doubles, - // so it groups with that window rather than standing alone. - return standardCadence(windowToken: cadence, label: "") - } - return standardCadence(windowToken: cadence, label: label) + return canonical } /// The qualifier of every bar in a caption row, in the same order. @@ -112,7 +123,8 @@ public enum QuotaWindowVocabulary { return bars.indices.map { index in let siblings = groups.indices.filter { $0 != index }.map { groups[$0] } return barQualifier( - forLabel: bars[index].label, + forCadence: groups[index], + label: bars[index].label, sharesCadence: siblings.contains(groups[index]) ) } @@ -277,9 +289,9 @@ public enum QuotaWindowVocabulary { ) -> String? { let lower = token.lowercased() if ["5h", "5hr", "5-hour", "5hours", "300m"].contains(lower) { return fiveHour } - if ["1d", "24h", "daily", "day"].contains(lower) { return oneDay } - if ["7d", "168h", "1w", "weekly", "week"].contains(lower) { return sevenDay } - if ["1m", "30d", "mo", "month", "monthly"].contains(lower) { return oneMonth } + if ["1d", "24h", "daily", "day", "1-day"].contains(lower) { return oneDay } + if ["7d", "168h", "1w", "weekly", "week", "7-day"].contains(lower) { return sevenDay } + if ["1m", "30d", "mo", "month", "monthly", "1-month"].contains(lower) { return oneMonth } if let seconds = ResetAlarmCadence.durationSeconds(lower) { return cadenceFromDuration(seconds, label: label, monthlyHint: monthlyHint) } diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index 07dd34b7..b1373e13 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -65,51 +65,122 @@ final class WidgetPresentationVocabularyTests: XCTestCase { /// caption token already showed, so a daily window read "1d daily". The /// qualifier names the kind of allowance instead. func testDailyQualifierDoesNotRestateTheCadenceToken() { - XCTAssertEqual( - QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap", sharesCadence: true), - "daily allowance" - ) - XCTAssertNotEqual( - QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap", sharesCadence: true), - "daily" + // The production label for a daily window is "1-day window": the + // widget's label pipeline rewrites the prose away. + let qualifier = QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.oneDay, + label: "1-day window", + sharesCadence: true ) + XCTAssertEqual(qualifier, "daily allowance") + XCTAssertNotEqual(qualifier, "daily") - let token = QuotaWindowVocabulary.popoverCaption(windowToken: "Daily", label: "Daily cap") + let token = QuotaWindowVocabulary.popoverCaption(windowToken: "1-day", label: "1-day window") XCTAssertEqual(token, "1d") - let caption = "\(token) \(QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap", sharesCadence: true))" + let caption = "\(token) \(qualifier)" XCTAssertEqual(caption, "1d daily allowance") XCTAssertNotEqual(caption, "1d daily") } - /// Kody rule "Avoid Substring Matching for Canonical Limit/Window Labels" - /// (binding, 2026-10-21): a canonical qualifier is matched on an exact - /// lexical token, never as a substring, so "nothourly" is not an hourly - /// window. - func testBarQualifierMatchesExactTokensNotSubstrings() { - XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "nothourly"), "") - XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "notweekly"), "") - XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "notmonthly"), "") - XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "notdaily"), "") - XCTAssertEqual(QuotaWindowVocabulary.barQualifier(forLabel: "unhourlyplan"), "") - - XCTAssertEqual( - QuotaWindowVocabulary.barQualifier(forLabel: "Hourly plan"), + /// Kody review 2026-10-21: the qualifier is named from the canonical + /// cadence the caption row prints, not from prose words in the label. + /// + /// The widget builds every window label through + /// `QuotaWindowVocabulary.windowLabel(...)`, which rewrites the label into + /// the hyphenated cadence copy ("7-day window"), so a matcher that looks + /// for "weekly" in the label never matched a label the widget renders and + /// the owner-approved "weekly" / "additional" pair never appeared. + func testBarQualifierIsNamedFromTheCadenceNotTheLabel() { + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "7-day window", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierWeekly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.oneMonth, + label: "1-month window", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierMonthly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.fiveHour, + label: "5-hour window", + sharesCadence: true + ), QuotaWindowVocabulary.barQualifierHourly ) + // A label that still carries prose cannot rename the cadence the row + // already prints. XCTAssertEqual( - QuotaWindowVocabulary.barQualifier(forLabel: "Weekly cap"), + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "Free weekly limit", + sharesCadence: true + ), QuotaWindowVocabulary.barQualifierWeekly ) + // A lone bar needs none. XCTAssertEqual( - QuotaWindowVocabulary.barQualifier(forLabel: "Monthly plan"), - QuotaWindowVocabulary.barQualifierMonthly + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "7-day window", + sharesCadence: false + ), + "" ) + // The extra allowance is the one case the label still names. XCTAssertEqual( - QuotaWindowVocabulary.barQualifier(forLabel: "Daily cap"), - QuotaWindowVocabulary.barQualifierDaily + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "Additional allowance", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierAdditional ) } + /// Kody rule "Avoid Substring Matching for Canonical Limit/Window Labels" + /// (binding, 2026-10-21): the prose fallback in the cadence group reads + /// whole lexical tokens, so "nothourly" is not an hourly window. + func testBarCadenceGroupFallbackMatchesExactTokensNotSubstrings() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "nothourly"), + QuotaWindowVocabulary.otherLimit + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "unhourlyplan"), + QuotaWindowVocabulary.otherLimit + ) + } + + /// The canonical labels the vocabulary produces must resolve back to + /// themselves when they are handed to the cadence lookup as a token. + /// + /// The widget passes a window's canonical cadence back through + /// `standardCadence(windowToken:label:)`, and an extra allowance is looked + /// up with an empty label, so a canonical label that did not resolve would + /// collapse that row to "Other limit" and lose the period it doubles. + func testCanonicalCadenceLabelsRoundTripThroughTheToken() { + for canonical in [ + QuotaWindowVocabulary.fiveHour, + QuotaWindowVocabulary.oneDay, + QuotaWindowVocabulary.sevenDay, + QuotaWindowVocabulary.oneMonth + ] { + XCTAssertEqual( + QuotaWindowVocabulary.standardCadence(windowToken: canonical, label: ""), + canonical, + "the canonical label '\(canonical)' must resolve back to itself." + ) + } + } + /// Kody review 2026-10-21: a lone bar printing a cadence word says the /// same thing twice, so the qualifier is only printed when a sibling bar /// shares the cadence. @@ -149,6 +220,32 @@ final class WidgetPresentationVocabularyTests: XCTestCase { ]) } + /// The same rule, driven by the labels the widget actually renders. + /// + /// A window label is built through `QuotaWindowVocabulary.windowLabel(...)`, + /// which rewrites the prose into the hyphenated cadence copy, so the + /// production shape of a 7-day window is "7-day window" and not "Free + /// weekly limit". The pair the owner approved has to come out of that + /// shape too (Kody review 2026-10-21). + func testProductionLabelsNameTheQualifierFromTheCadence() { + let sharedWeek = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "Additional allowance") + ]) + XCTAssertEqual(sharedWeek, [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierAdditional + ]) + + // Two windows of different periods are not siblings, so a lone 7-day + // bar prints no qualifier at all. + let loneWeek = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.BarCaption(cadence: "5-hour", label: "5-hour window") + ]) + XCTAssertEqual(loneWeek, ["", ""]) + } + /// The cadence group is what decides whether two bars share a cadence, so /// it has to follow the period rather than the wording. func testBarCadenceGroupFollowsThePeriod() { @@ -168,5 +265,23 @@ final class WidgetPresentationVocabularyTests: XCTestCase { QuotaWindowVocabulary.barCadenceGroup(cadence: "1m", label: "Billing cycle"), QuotaWindowVocabulary.oneMonth ) + // The window's own cadence wins over the label's prose, because that + // is the cadence the caption token beside the bar prints. A group + // that disagreed with the token gave a bar a qualifier that named a + // period its own token contradicted (Kody review 2026-10-21). + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "5h", label: "Weekly cap"), + QuotaWindowVocabulary.fiveHour + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.sevenDay + ) + // An extra allowance is looked up without its label, so it keeps the + // period it doubles instead of collapsing to "Other limit". + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "7-day", label: "Additional allowance"), + QuotaWindowVocabulary.sevenDay + ) } } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index 767fc54d..058f4dd6 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -176,7 +176,12 @@ public struct WidgetPlatformItem: Identifiable, Equatable, Sendable { } ) return windows.indices.map { index in - windows[index].withBarQualifier(qualifiers[index]) + // A caller that named the qualifier keeps it. The sibling + // resolution only fills in a qualifier the caller left blank, so + // the public `barQualifier:` parameter does not silently vanish on + // a platform with two or more windows (Kody review 2026-10-21). + let qualifier = qualifiers[index].isEmpty ? windows[index].barQualifier : qualifiers[index] + return windows[index].withBarQualifier(qualifier) } } From 7314aa30cbeac19a7dab8ae06dcae81be3d2be62 Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 12:05:10 -0500 Subject: [PATCH 42/50] AFC-459: hand the staged ASC key to the consumer without entering the shell Closes the three review findings on the credential path: the reader wrote private key material to stdout, the ship script let a file credential bypass environment-first resolution, and the two CI callers provided only a path. The hosted iOS ship workflow stages the App Store Connect key to a mode-600 file and publishes only its path. The shell layer read that file into a shell variable before handing the key to the client in the child's environment, so the key was in the shell's memory, in a command substitution, and in the shell's own environment for the length of the run. The reader now execs the consumer, so the key goes from the file straight into the consumer's environment and never enters a shell at all. asc-key.mjs gains an exec command. It opens the path once with O_NOFOLLOW, reads from that same descriptor, and spawns the consumer with the key in the consumer's environment. It reports a path that names nothing apart from every other failure, and it passes the consumer's exit code on, so a missing key and a failing consumer stay distinguishable. The read and validate commands no longer print the key. A key on stdout is a key in every log that captures it, so they report whether the file holds a usable key and nothing else. The order of the credential sources is now the environment first and a named file second, in every consumer. A disk credential never overrides the environment, because a stale or foreign value in an operator's shell would sign the upload with the wrong key and App Store Connect would answer 401 without saying which input was wrong. The two hosted-runner consumers use the new command, the standing-testers step in ios-ship.yml and run_client in ios-install-appstore-profiles.sh. ios-enable-apns.mjs stops shelling out to read the key and imports the reader directly, so the key is read once, by the code that owns it. The tests cover the handoff, the missing key, the consumer's exit code, and the process table. While the consumers are waiting on App Store Connect, no process on the machine carries the key in its command line. Refs AFC-459 --- .github/workflows/ios-ship.yml | 17 +-- scripts/ios-enable-apns.mjs | 17 +-- scripts/ios-fleet/asc-key.mjs | 67 ++++++++++- scripts/ios-fleet/ship-testflight.sh | 130 +++++++++++++--------- scripts/ios-fleet/test-asc-credentials.sh | 58 ++++++---- scripts/ios-fleet/test-asc-key.test.mjs | 68 ++++++++--- scripts/ios-install-appstore-profiles.sh | 22 ++-- 7 files changed, 251 insertions(+), 128 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 93024f5c..ec53164e 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -339,15 +339,18 @@ jobs: fi set +e # The client resolves the signing key from its environment and never - # from a path (Kody rule 42), so the staged key is read here and - # handed to the child through the child's environment. It stays out - # of argv, out of $GITHUB_ENV and out of the log. - asc_pem="" + # from a path (Kody rule 42), so the staged key is handed to the + # child through the child's environment by the hardened reader. The + # key stays out of argv, out of stdout, out of $GITHUB_ENV and out + # of the log. if [ -n "${ASC_KEY_PATH:-}" ]; then - asc_pem="$(node scripts/ios-fleet/asc-key.mjs read "$ASC_KEY_PATH" 2>/dev/null)" || asc_pem="" + node scripts/ios-fleet/asc-key.mjs exec "$ASC_KEY_PATH" -- \ + node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" + rc=$? + else + node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" + rc=$? fi - ASC_KEY_P8="$asc_pem" node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" - rc=$? set -e if [ "$rc" -ne 0 ]; then echo "::warning title=TestFlight standing testers::tester sync exited ${rc}; see the standing-testers lines above. The TestFlight upload itself succeeded." diff --git a/scripts/ios-enable-apns.mjs b/scripts/ios-enable-apns.mjs index d2258c75..f538d172 100644 --- a/scripts/ios-enable-apns.mjs +++ b/scripts/ios-enable-apns.mjs @@ -3,6 +3,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; +import { readPrivateKeyFile } from './ios-fleet/asc-key.mjs'; + export const bundleIdentifier = 'com.simplewithus.codecaps.ios'; export const pushProfileName = 'CodeCaps Companion AppStore'; @@ -173,8 +175,8 @@ function inspectProductionProfile(encoded) { // in the child's environment. // // The key never reaches a command line, a log, the environment file, or a -// temporary file: the reader prints it on stdout, the caller captures it, -// and only the child process receives it. +// temporary file: the reader keeps it in the file, this function keeps it in +// the process, and only the child process receives it. // // A path that names nothing is not a credential: the run falls through and // the client resolves the credential the way it does today, from an exported @@ -182,15 +184,8 @@ function inspectProductionProfile(encoded) { export function stagedKeyEnvironment(env = process.env) { const keyPath = env.ASC_KEY_PATH; if (!keyPath) return {}; - const reader = fileURLToPath(new URL('./ios-fleet/asc-key.mjs', import.meta.url)); - try { - const pem = execFileSync(process.execPath, [reader, 'read', keyPath], { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 64 * 1024 - }); - return pem ? { ASC_KEY_P8: pem } : {}; - } catch { - return {}; - } + const read = readPrivateKeyFile(keyPath); + return read.pem ? { ASC_KEY_P8: read.pem } : {}; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/scripts/ios-fleet/asc-key.mjs b/scripts/ios-fleet/asc-key.mjs index 3d466932..5a519643 100644 --- a/scripts/ios-fleet/asc-key.mjs +++ b/scripts/ios-fleet/asc-key.mjs @@ -19,11 +19,22 @@ * file is a hard stop. * * The command line never carries key material: - * read read a staged key file and print the validated key - * validate read a key on stdin and print the validated key + * read check that a staged key file holds a usable key + * validate check that a key on stdin holds a usable key * write read a key on stdin and write it to a mode-600 file + * exec -- [args...] + * run a command with the staged key in its environment + * + * WHY NOTHING PRINTS THE KEY (2026-10-21, Kody rule 42 on PR #271): + * A key on stdout is a key in a log, in a CI transcript, in a process + * listing and in whatever captures that output next. So `read` and + * `validate` report the STATUS of a key and never the key itself, and the + * only way the key reaches a consumer is `exec`, which puts it in the + * child's environment as ASC_KEY_P8: never in an argument, never on + * stdout, never in a log line. */ import { closeSync, constants, fstatSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { createPrivateKey } from "node:crypto"; // A private key is a few kilobytes. Anything larger is not one. @@ -110,6 +121,31 @@ async function readStdin() { return Buffer.concat(chunks).toString("utf8"); } +// Runs a command with the staged key in its environment. +// +// The key goes into the child's environment as ASC_KEY_P8, which is where the +// client looks for it. It is never an argument, never reaches this process's +// stdout or stderr, and never reaches a log line. +// +// The child's exit code is returned, so a caller that runs the client through +// this keeps the client's own result. +export function execWithKey(keyPath, command, args) { + const read = readPrivateKeyFile(keyPath); + if (read.missing) return { missing: true }; + if (read.error) return { error: read.error }; + const child = spawnSync(command, args, { + stdio: "inherit", + env: { ...process.env, ASC_KEY_P8: read.pem } + }); + if (child.error) { + return { error: `the command could not be started: ${child.error.message}` }; + } + if (child.signal) { + return { error: `the command was killed by ${child.signal}` }; + } + return { status: typeof child.status === "number" ? child.status : 1 }; +} + // The exit codes the shell layer reads: // 0 the key was read // 1 the path names something that is not a usable key @@ -132,7 +168,9 @@ async function main() { console.error(`asc-key: ${read.error}`); return 1; } - process.stdout.write(read.pem); + // The status is reported and the key is not: a key on stdout is a key + // in every log that captures it. + console.log(`asc-key: ${target} holds a PEM private key`); return 0; } if (command === "validate") { @@ -141,7 +179,8 @@ async function main() { console.error("asc-key: the value is not a PEM private key"); return 1; } - process.stdout.write(pem); + // The key is not printed here either: see the read command. + console.log("asc-key: the value is a PEM private key"); return 0; } if (command === "write") { @@ -156,7 +195,25 @@ async function main() { } return 0; } - console.error("asc-key: usage: asc-key.mjs read | validate | write "); + if (command === "exec") { + const rest = process.argv.slice(3); + const separator = rest.indexOf("--"); + if (!target || separator < 0 || separator === rest.length - 1) { + console.error("asc-key: usage: asc-key.mjs exec -- [args...]"); + return 1; + } + const child = execWithKey(target, rest[separator + 1], rest.slice(separator + 2)); + if (child.missing) { + console.error(`asc-key: the key file ${target} does not exist`); + return EXIT_MISSING; + } + if (child.error) { + console.error(`asc-key: ${child.error}`); + return 1; + } + return child.status; + } + console.error("asc-key: usage: asc-key.mjs read | validate | write | exec -- [args...]"); return 1; } diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index c00ec179..acb171d9 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -81,12 +81,13 @@ # fleet runners). # # Secrets (never printed): -# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (env first, e.g. CI; else Infisical via +# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (env first, e.g. CI; else Infisical via # `fleet-secrets get` from Fleet-OPS /asc: ASC_KEY_ID, ASC_ISSUER_ID and the .p8 in ASC_KEY_P8, # written to a 0600 temp file and deleted on exit) -# A named ASC_KEY_PATH is read here, never by the node client: the key is -# handed to the client in ASC_KEY_P8, because a path on disk is not an -# approved secret source. +# A named ASC_KEY_PATH is never read by the node client, because a path on disk +# is not an approved secret source. The key material stays in that file: the +# client is handed the key in the child's environment (see asc_client), and +# altool and Xcode are given the path. # or Xcode-signed-in session for destination=upload export # SENTRY_AUTH_TOKEN (dSYM + Size Analysis upload; env or `fleet-secrets get`) # SENTRY_DSN (injected into xcodebuild so Cocoa plist $(SENTRY_DSN) is set) @@ -214,65 +215,72 @@ fs_get() { # environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain # credentials. A caller-supplied file is not one of them, so the node client # never reads a key file. This shell layer already owns the credential for -# altool and Xcode, so it reads the staged key here and exports it as -# ASC_KEY_P8, which is where the client looks for it. The hosted runner stages -# the key to a mode-600 file and publishes only its path, so this is the route -# the CI job takes as well. +# altool and Xcode, so it hands the key to the client in the environment, +# which is where the client looks for it. # -# The order is deliberate: -# 1. ASC_KEY_PATH, when the operator named one and it holds a usable key -# 2. ASC_KEY_P8, when it holds a PEM private key and comes with its key id +# THE ENVIRONMENT IS RESOLVED FIRST (2026-10-21, Kody rule 42 on PR #271): +# 1. ASC_KEY_P8, when it holds a PEM private key and comes with its key id +# 2. a named ASC_KEY_PATH, used as a FILE and never read into a variable # 3. Infisical Fleet-OPS /asc (or /authkey--p8) -# An ambient ASC_KEY_P8 never replaces a named key file: a stale or foreign -# value in an operator's shell would sign the upload with the wrong key, and -# App Store Connect would answer 401 without saying which input was wrong. +# A disk credential never overrides the environment: a stale or foreign value +# in an operator's shell would sign the upload with the wrong key, and App +# Store Connect would answer 401 without saying which input was wrong. The +# staged file stays a file: the key material never enters this shell, and the +# client is handed the key in the child's environment by the hardened reader +# (see asc_client). load_secrets() { AUTH_MODE="none" local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower - local key_pem="" key_path="" rc=0 + local key_pem="" key_path="" staged_path="" rc=0 + # The staged key file, when it is the credential in play. The client is run + # through the hardened reader, which puts the key in the child's + # environment; the key itself never enters this shell. + ASC_STAGED_KEY_PATH="" + + # 1. An ambient ASC_KEY_P8. It is only usable when it really is a PEM + # private key and comes with the key id and issuer it belongs to. It is + # resolved first, so nothing on disk can override it. + if [[ -n "${ASC_KEY_P8:-}" ]]; then + if [[ -z "$kid" || -z "$iss" ]]; then + log "ASC_KEY_P8 is set without ASC_KEY_ID and ASC_ISSUER_ID; ignoring it" + unset ASC_KEY_P8 + else + rc=0 + node "${FLEET_DIR}/asc-key.mjs" validate >/dev/null 2>&1 <<< "$ASC_KEY_P8" || rc=$? + if [[ "$rc" -ne 0 ]]; then + log "ASC_KEY_P8 is set but does not hold a PEM private key; ignoring it" + unset ASC_KEY_P8 + else + key_pem="$ASC_KEY_P8" + fi + fi + fi - # 1. A key file the operator named. It is opened once with O_NOFOLLOW and - # read from that descriptor, so a path that is swapped for a symlink - # cannot be followed. A path that names nothing falls through to the - # fleet credential instead of aborting the run. - if [[ -n "$kid" && -n "$iss" && -n "${ASC_KEY_PATH:-}" ]]; then + # 2. A key file the operator named. It is checked with the hardened + # reader, which reports whether the file holds a usable key and never + # prints the key, so the key material stays in the file. A path that + # names nothing falls through to the fleet credential instead of + # aborting the run. + if [[ -z "$key_pem" && -n "$kid" && -n "$iss" && -n "${ASC_KEY_PATH:-}" ]]; then local expanded expanded="$(expand_tilde "$ASC_KEY_PATH")" rc=0 - key_pem="$(node "${FLEET_DIR}/asc-key.mjs" read "$expanded")" || rc=$? + node "${FLEET_DIR}/asc-key.mjs" read "$expanded" >/dev/null || rc=$? if [[ "$rc" -eq 0 ]]; then - key_path="$expanded" + staged_path="$expanded" else if [[ "$rc" -eq 3 ]]; then log "ASC_KEY_PATH names a file that does not exist; falling back to the fleet credential" else log "ASC_KEY_PATH does not hold a usable private key; falling back to the fleet credential" fi - key_pem="" - fi - fi - - # 2. An ambient ASC_KEY_P8. It is only usable when it really is a PEM - # private key and comes with the key id and issuer it belongs to. - if [[ -z "$key_pem" && -n "${ASC_KEY_P8:-}" ]]; then - if [[ -z "$kid" || -z "$iss" ]]; then - log "ASC_KEY_P8 is set without ASC_KEY_ID and ASC_ISSUER_ID; ignoring it" - unset ASC_KEY_P8 - else - rc=0 - key_pem="$(node "${FLEET_DIR}/asc-key.mjs" validate 2>/dev/null <<< "$ASC_KEY_P8")" || rc=$? - if [[ "$rc" -ne 0 ]]; then - log "ASC_KEY_P8 is set but does not hold a PEM private key; ignoring it" - unset ASC_KEY_P8 - key_pem="" - fi fi fi # 3. The key id, issuer and private key must belong together: with nothing named in the # environment all three come from /asc; an env key id that is not the /asc one gets its # own .p8 from /authkey--p8, never the /asc key. - if [[ -z "$key_pem" ]]; then + if [[ -z "$key_pem" && -z "$staged_path" ]]; then fkid="$(fs_get ASC_KEY_ID /asc)" || fkid="" if [[ -z "$kid" ]]; then kid="$fkid" @@ -282,7 +290,7 @@ load_secrets() { iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" fi if [[ -z "$kid" || -z "$iss" ]]; then - log "ASC credentials missing: set ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_PATH or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc" + log "ASC credentials missing: set ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_P8 or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc" return 0 fi if [[ "$kid" == "$fkid" ]]; then @@ -300,8 +308,12 @@ load_secrets() { fi # The key has to be in a mode-600 file for altool and Xcode, and in the - # environment for the node client, which never reads a file. - if [[ -z "$key_path" ]]; then + # environment for the node client, which never reads a file. A staged key + # file is already a mode-600 file, so it is used where it lies. + if [[ -n "$staged_path" ]]; then + key_path="$staged_path" + ASC_STAGED_KEY_PATH="$staged_path" + elif [[ -z "$key_path" ]]; then if [[ -z "$ASC_TMP_DIR" ]]; then ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" fi @@ -315,13 +327,31 @@ load_secrets() { fi export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="$key_path" # The client takes the key from the environment: a path is not an approved - # secret source, so it never reads one. - export ASC_KEY_P8="$key_pem" + # secret source, so it never reads one. A staged key file is handed over + # by the hardened reader instead, in the child's environment. + if [[ -n "$key_pem" ]]; then + export ASC_KEY_P8="$key_pem" + fi key_pem="" AUTH_MODE="api_key" return 0 } +# Run the App Store Connect client. +# +# The client takes the key from its environment and never reads a file (Kody +# rule 42). When the credential in play is a staged key file, the hardened +# reader hands the key to the client in the child's environment: the key is +# never an argument, never on this script's stdout, and never in a log line. +asc_client() { + if [[ -n "${ASC_STAGED_KEY_PATH:-}" ]]; then + node "${FLEET_DIR}/asc-key.mjs" exec "$ASC_STAGED_KEY_PATH" -- \ + node "${FLEET_DIR}/asc-api.mjs" "$@" + else + node "${FLEET_DIR}/asc-api.mjs" "$@" + fi +} + # Map ship APP_KEY to Sentry project slug in org simple-with-us. sentry_project_for_app() { case "${APP_KEY}" in @@ -513,7 +543,7 @@ asc_latest_seq() { # stdout, and this function's stdout IS the sequence number. local errf rc errf="$(mktemp "${TMPDIR:-/tmp}/asc-seq-err.XXXXXX")" - out=$(node "${FLEET_DIR}/asc-api.mjs" latest-build-seq "$BUNDLE_ID" "$prefix" "$plat" 2>"$errf") + out=$(asc_client latest-build-seq "$BUNDLE_ID" "$prefix" "$plat" 2>"$errf") rc=$? set -e if [[ -s "$errf" ]]; then @@ -1092,7 +1122,7 @@ fi if [[ -n "$UPLOAD_ONLY_IPA" ]]; then [[ -f "$UPLOAD_ONLY_IPA" ]] || die "package not found: $UPLOAD_ONLY_IPA" - [[ "$AUTH_MODE" == "api_key" ]] || die "upload-only requires ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (env) or Infisical /asc (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8)" + [[ "$AUTH_MODE" == "api_key" ]] || die "upload-only requires ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 (env) or Infisical /asc (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8)" link_private_key "$ASC_KEY_PATH" "$ASC_KEY_ID" log "uploading existing package via altool (api key)" set +e @@ -1181,7 +1211,7 @@ ensure_tf_ready() { IOS_TF_NOTES_PREV_SHA="$PREV_SHIP_SHA" \ IOS_TF_NOTES_APP="$DISPLAY_NAME" \ IOS_TF_NOTES_IOS_PREFIX="$IOS_PATH_PREFIX" \ - node "${FLEET_DIR}/asc-api.mjs" ensure-tf-ready "$BUNDLE_ID" "$BUILD_NUM" "$MARKETING" \ + asc_client ensure-tf-ready "$BUNDLE_ID" "$BUILD_NUM" "$MARKETING" \ >"${LOG_DIR}/ensure-tf-ready.json" 2>"${LOG_DIR}/ensure-tf-ready.err" local rc=$? set -e @@ -1422,7 +1452,7 @@ if [[ "$AUTH_MODE" != "api_key" ]]; then log " 1) Create ASC API key (App Manager+) in App Store Connect" log " 2) Store ASC_KEY_ID and ASC_ISSUER_ID in Infisical Fleet-OPS /asc" log " 3) Store the .p8 contents as ASC_KEY_P8 in the same Infisical folder" - log " 4) Or export ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH for this run" + log " 4) Or export ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 for this run" log " 5) Re-run: bash $0 $APP_KEY --upload-only \"$PACKAGE\"" log "Also ensure App Store Connect has an app record for ${BUNDLE_ID}." exit 3 diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index d1398497..bf2ae994 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -95,7 +95,29 @@ out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH -u ASC_KEY_P8 bash -c grep -q '^ASC_KEY_ID /asc$' "$FAKE_LOG" || fail "did not ask Infisical /asc for the key id" grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "did not ask Infisical /asc for the key" -# 2. An explicit ASC_* environment wins and fleet-secrets is not called. +# 2. The environment wins over a named key file. A disk credential never +# overrides the environment (Kody rule 42 on PR #271): a stale or foreign +# value in an operator's shell would sign the upload with the wrong key. +: > "$FAKE_LOG" +cp "${tmp}/asc.p8" "${tmp}/named-key.p8" +chmod 600 "${tmp}/named-key.p8" +ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer \ + ASC_KEY_P8="$(<"${tmp}/other.p8")" ASC_KEY_PATH="${tmp}/named-key.p8" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 ]] + # The environment key is the credential in play. + [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/other.p8")" ]] + [[ "$(printf "%s" "$ASC_KEY_P8")" != "$(cat "'"$tmp"'/asc.p8")" ]] + # The named file was not read: no file was staged for the client. + [[ -z "${ASC_STAGED_KEY_PATH:-}" ]] +' || fail "a named key file overrode the environment" +[[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" + +# 2a. A named key file is used as a FILE: the key material never enters this +# shell, the file is used where it lies, and the client is handed the key in +# the child's environment by the hardened reader. : > "$FAKE_LOG" cp "${tmp}/asc.p8" "${tmp}/env-key.p8" chmod 600 "${tmp}/env-key.p8" @@ -106,28 +128,18 @@ env -u ASC_KEY_P8 ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer ASC_KEY_PATH="$ [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 && -z "$ASC_TMP_DIR" ]] # The named file is used where it lies: no copy is made of it. [[ "$ASC_KEY_PATH" == "'"${tmp}"'/env-key.p8" ]] - # The client still gets the key in the environment. - [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] -' || fail "environment credentials were not honored" -[[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" + # The key stays in the file: it never enters this shell. + [[ -z "${ASC_KEY_P8:-}" ]] + # The client is handed the key from the file, in the child environment. + [[ "$ASC_STAGED_KEY_PATH" == "'"${tmp}"'/env-key.p8" ]] +' || fail "the named key file was not used as a file" +[[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the named file was usable" -# 2a. A stale ambient ASC_KEY_P8 does not hijack a working ASC_KEY_PATH credential. -# The named file is the credential in play; the stale secret in the environment -# must not replace it, because the upload would then be signed with a key -# App Store Connect does not expect. -: > "$FAKE_LOG" -cp "${tmp}/asc.p8" "${tmp}/named-key.p8" -chmod 600 "${tmp}/named-key.p8" -ASC_KEY_ID=ENVKEY0003 ASC_ISSUER_ID=env-issuer \ - ASC_KEY_P8="$(<"${tmp}/other.p8")" ASC_KEY_PATH="${tmp}/named-key.p8" bash -c ' - set -euo pipefail - source "'"$funcs"'" - load_secrets - [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0003 ]] - [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] - [[ "$(printf "%s" "$ASC_KEY_P8")" != "$(cat "'"$tmp"'/other.p8")" ]] -' || fail "a stale ambient ASC_KEY_P8 hijacked the named key file" -[[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" +# 2b. A staged key file is never read into a variable: the shell that owns it +# only ever holds its path. +if grep -nE 'asc-key\.mjs" read .*\)" \|\|' "$script" >/dev/null; then + fail "ship-testflight.sh reads the key material into a variable" +fi # 2b. An ambient ASC_KEY_P8 that is not a PEM private key is ignored, and the # run fails closed rather than signing with something that is not a key. @@ -249,4 +261,4 @@ if grep -nE '(~|\$\{?HOME\}?)/\.secrets' "$script" >/dev/null; then fail "ship-testflight.sh still names the old secrets folder" fi -echo "ok: ASC credential resolution (env first, then Infisical, temp key removed)" +echo "ok: ASC credential resolution (env first, then the named file as a file, then Infisical, temp key removed)" diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs index 4c247b41..89921223 100644 --- a/scripts/ios-fleet/test-asc-key.test.mjs +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -116,28 +116,60 @@ test("a file that is not a private key is refused", () => { assert.match(read.error, /does not hold a PEM private key/); }); -test("the command line reads a staged key and reports a missing one", async () => { +test("the command line reports the status of a staged key and never prints it", async () => { + // A key on stdout is a key in every log that captures it, so the reader + // reports whether the file is usable and nothing else. const path = staged("cli.p8"); const ok = await run(["read", path]); - assert.equal(ok.code, 0); - assert.equal(ok.stdout.trim(), keyPem.trim()); + assert.equal(ok.code, 0, `the staged key was refused: ${ok.stderr}`); + assert.match(ok.stdout, /holds a PEM private key/, `the status was not reported:\n${ok.stdout}`); + assert.doesNotMatch(ok.stdout, /PRIVATE KEY/, "the key reached stdout"); + assert.doesNotMatch(ok.stderr, /PRIVATE KEY/, "the key reached stderr"); const missing = await run(["read", join(root, "no-such-key.p8")]); - assert.notEqual(missing.code, 0); + assert.equal(missing.code, 3, "a path that names nothing must report EXIT_MISSING"); assert.match(missing.stderr, /does not exist/); assert.equal(missing.stdout, "", "a missing key must not put anything on stdout"); + + const malformed = await run(["read", staged("bad.p8", "this is not a private key\n")]); + assert.equal(malformed.code, 1, "a file that is not a key must be refused"); + assert.match(malformed.stderr, /does not hold a PEM private key/); + assert.equal(malformed.stdout, "", "a refused key must not put anything on stdout"); }); test("the key never travels in the command arguments", async () => { - // The key goes in on stdin and comes out on stdout, so it is never an - // argument of any process. - const path = staged("argv.p8"); + // The key goes in on stdin, and the command reports the status of it + // without printing it, so it is never an argument of any process and never + // on stdout. const result = await run(["validate"], keyPem); assert.equal(result.code, 0); - assert.equal(result.stdout.trim(), keyPem.trim()); + assert.match(result.stdout, /is a PEM private key/, `the status was not reported:\n${result.stdout}`); + assert.doesNotMatch(result.stdout, /PRIVATE KEY/, "the key reached stdout"); assert.ok(!result.stderr.includes("PRIVATE KEY"), "the key envelope reached a log line"); }); +test("the exec command hands the key to the child in its environment", async () => { + // This is the handoff the hosted runner uses: the staged key reaches the + // consumer in the child's environment, and in nowhere else. Not in an + // argument, not on stdout, not on stderr, not in a log line. + const path = staged("exec.p8"); + const child = await run(["exec", path, "--", process.execPath, "-e", + "process.stdout.write('seen:' + (process.env.ASC_KEY_P8 ? 'yes' : 'no') + ':' + String(process.env.ASC_KEY_P8 || '').length)"]); + assert.equal(child.code, 0, `the child did not run: ${child.stderr}`); + assert.match(child.stdout, /^seen:yes:\d+$/, `the child did not receive the key:\n${child.stdout}`); + assert.doesNotMatch(child.stdout + child.stderr, /PRIVATE KEY/, "the key reached the output"); +}); + +test("the exec command reports a missing key and passes the child's exit code on", async () => { + const missing = await run(["exec", join(root, "no-such-key.p8"), "--", process.execPath, "-e", "process.exit(0)"]); + assert.equal(missing.code, 3, "a path that names nothing must report EXIT_MISSING"); + assert.match(missing.stderr, /does not exist/); + + const path = staged("exit.p8"); + const child = await run(["exec", path, "--", process.execPath, "-e", "process.exit(7)"]); + assert.equal(child.code, 7, "the child's exit code was not passed on"); +}); + test("the command line writes a key to a file only its owner can read", async () => { const path = join(root, "written.p8"); const result = await run(["write", path], keyPem); @@ -155,20 +187,21 @@ test("the command line refuses a value that is not a key", async () => { test("the staged key reaches the client through the environment", async () => { // This is the hosted CI path end to end: the runner stages the key to a - // mode-600 file and publishes only its path, the shell layer reads that - // file, and the client takes the key from the environment. The client - // reads no file at all, which is why the path is in the environment here - // and the file is still the credential in play. + // mode-600 file and publishes only its path, the hardened reader hands + // that key to the client in the child's environment, and the client takes + // the key from the environment. The client reads no file at all, which is + // why the path is in the environment here and the file is still the + // credential in play. const path = staged("ci.p8"); - const read = await run(["read", path]); - assert.equal(read.code, 0, `the staged key was not read: ${read.stderr}`); - const client = spawn(process.execPath, [join(here, "asc-api.mjs"), "GET", "http://127.0.0.1:1/v1/apps"], { + const client = spawn(process.execPath, [ + helper, "exec", path, "--", process.execPath, join(here, "asc-api.mjs"), "GET", "http://127.0.0.1:1/v1/apps" + ], { env: { - ...process.env, + PATH: process.env.PATH, + HOME: process.env.HOME, ASC_API_BASE: "http://127.0.0.1:1", ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_P8: read.stdout, ASC_KEY_PATH: path }, stdio: ["ignore", "pipe", "pipe"] @@ -179,6 +212,7 @@ test("the staged key reaches the client through the environment", async () => { await new Promise((resolve) => client.on("close", resolve)); assert.match(stderr, /asc-auth: using ASC_KEY_P8 environment/, `the client did not take the key from the environment:\n${stderr}`); assert.doesNotMatch(stderr, /using ASC_KEY_PATH environment/); + assert.doesNotMatch(stderr, /PRIVATE KEY/, "the key reached the output"); }); // The consumers that call the client as a child process. diff --git a/scripts/ios-install-appstore-profiles.sh b/scripts/ios-install-appstore-profiles.sh index c7604bfa..72df7014 100755 --- a/scripts/ios-install-appstore-profiles.sh +++ b/scripts/ios-install-appstore-profiles.sh @@ -22,26 +22,18 @@ chmod 700 "$DEST" # The client takes the key from the environment, because a path on disk is not # an approved secret source (Kody rule 42). The hosted runner stages the key -# to a mode-600 file and publishes only its path, so the staged key is read -# here, with the hardened reader, and handed to the child in the child's -# environment. The key never reaches a command line, a log, the environment -# file, or a temporary file. +# to a mode-600 file and publishes only its path, so the staged key is handed +# to the child in the child's environment by the hardened reader. The key +# never reaches a command line, a log, the environment file, or a temporary +# file, and it never reaches this shell: the reader keeps it in the file and +# puts it in the child's environment. # # A path that names nothing is not a credential: the run falls through and the # client resolves the credential the way it does today, from an exported # ASC_KEY_P8 or from fleet-secrets. -staged_key="" -if [[ -n "${ASC_KEY_PATH:-}" ]]; then - read_rc=0 - staged_key="$(node "$KEY_READER" read "$ASC_KEY_PATH")" || read_rc=$? - if [[ "$read_rc" -ne 0 ]]; then - staged_key="" - fi -fi - run_client() { - if [[ -n "$staged_key" ]]; then - ASC_KEY_P8="$staged_key" node "$CLIENT" "$@" + if [[ -n "${ASC_KEY_PATH:-}" ]]; then + node "$KEY_READER" exec "$ASC_KEY_PATH" -- node "$CLIENT" "$@" else node "$CLIENT" "$@" fi From 78fb344b169f3cc5a2534a2c8e3a0847f7f183b8 Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 12:05:10 -0500 Subject: [PATCH 43/50] CC-169: check the review submission's platform before writing to it Closes the four review findings on the submission path: a run continued after a rejected credential, a named submission's platform was patched before its binding was verified, a refusal discarded the blockers, and an automatically discovered submission could be submitted without the requested platform. The mock binding handler now validates the body it is handed. The submit-review command sets the platform on the submission it is about to send, and it set it before it had read the binding. A submission that holds another version is left alone, and a submission that is left alone must not be mutated either, so the write happened on a submission the command was going to skip. The platform is checked before anything is written. A submission that carries another platform is never rewritten to one the operator did not ask for, and a refusal mutates nothing. A platform that was not named is this command's default, and a default must never rewrite a submission somebody else prepared for another platform. The platform is set on the candidate that is actually going to be submitted, and only after the binding has been read and made. It is then read back, so a change that was ignored or only half applied cannot slip into the call that cannot be undone. A rejected credential stops the run. The calls that follow the binding include the submit, which cannot be undone, so a 401 or 403 on the binding ends the run instead of issuing them with a credential App Store Connect has already refused. The same is true of a refusal on the submit itself, and a refusal there now prints the blockers the way a refused binding does. Refs CC-169 --- scripts/ios-fleet/asc-api.mjs | 124 ++++++++++-- scripts/ios-fleet/test-asc-submit.mjs | 263 ++++++++++++++++++++++++-- 2 files changed, 354 insertions(+), 33 deletions(-) diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 954b826b..2fc2967e 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -1021,6 +1021,11 @@ async function main() { if (method === "submit-review") { const versionId = path; const submissionId = body; + // The platform the operator asked for by name, and the platform in play. + // They are not the same thing: a platform that was not asked for is + // this command's default, and the default must never rewrite a + // submission somebody else prepared for another platform. + const platformRequested = typeof arg4 === "string" && arg4.length > 0; const platform = arg4 || "IOS"; if (!versionId) { console.error("Usage: node asc-api.mjs submit-review [reviewSubmissionId] [platform]"); @@ -1197,14 +1202,20 @@ async function main() { // it. A submission bound to nothing is the one that gets this version. const blockers = []; let candidates = []; + // The platform every candidate carries, so the platform can be checked + // against the one in play before the irreversible call goes out. + const candidatePlatforms = new Map(); if (submissionId) { candidates = [submissionId]; // A named submission is taken as given, but two things about it still // have to be checked: a submission that was already sent cannot be // sent again, and its platform has to be the one asked for, because a // macOS submission for an iOS version would send the wrong build to - // review. The platform is a settable attribute, so it is set here - // rather than left to the one call that cannot be undone. + // review. + // + // The platform is NOT set here. The binding is read first, because a + // submission that holds another version is left alone, and a + // submission that is left alone must not be mutated either. const named = await api("GET", `/v1/reviewSubmissions/${submissionId}`); if (!named.ok) { console.error(`submit-review: ${describeError(named)}`); @@ -1224,20 +1235,10 @@ async function main() { process.exitCode = 2; return; } - if (namedAttributes.platform !== platform) { - const setPlatform = await api("PATCH", `/v1/reviewSubmissions/${submissionId}`, JSON.stringify({ - data: { - type: "reviewSubmissions", - id: submissionId, - attributes: { platform } - } - })); - if (!setPlatform.ok) { - console.error(`submit-review: the platform could not be set: ${describeError(setPlatform)}`); - process.exitCode = 2; - return; - } - } + candidatePlatforms.set( + submissionId, + typeof namedAttributes.platform === "string" ? namedAttributes.platform : null + ); } else { if (typeof appId !== "string" || appId.length === 0) { console.error("submit-review: not submitting, the version names no app"); @@ -1257,10 +1258,20 @@ async function main() { // A submission that was already sent cannot be sent again. if (attributes.submittedDate !== null && attributes.submittedDate !== undefined) return false; // A submission for another platform would put the wrong build in - // review, so it is not a candidate. + // review, so it is not a candidate. A submission with no platform + // at all is a candidate: the platform is set on it and read back + // before the irreversible call, so it never goes to review with + // the wrong platform. return typeof attributes.platform !== "string" || attributes.platform === platform; }) - .map((s) => s.id); + .map((s) => { + const attributes = isPlainObject(s.attributes) ? s.attributes : {}; + candidatePlatforms.set( + s.id, + typeof attributes.platform === "string" ? attributes.platform : null + ); + return s.id; + }); } if (candidates.length === 0) { // The app has no draft submission at all, so one is made. The @@ -1290,6 +1301,12 @@ async function main() { return; } candidates = [createdId]; + // The platform went on it at create time, so it does not have to be + // set again. + const createdPlatform = isPlainObject(created.parsed?.data?.attributes) + ? created.parsed.data.attributes.platform + : null; + candidatePlatforms.set(createdId, typeof createdPlatform === "string" ? createdPlatform : platform); } // Is this submission bound to the version? @@ -1325,6 +1342,20 @@ async function main() { ); continue; } + // The platform is checked before anything is written: a submission + // that carries another platform is never rewritten to one the operator + // did not ask for, and a refusal mutates nothing. + const candidatePlatform = candidatePlatforms.has(candidate) + ? candidatePlatforms.get(candidate) + : null; + if (candidatePlatform !== platform && !platformRequested && typeof candidatePlatform === "string") { + console.error( + `submit-review: not submitting, the review submission ${candidate} is for ${candidatePlatform} ` + + "and no platform was requested. Name the platform to submit it." + ); + process.exitCode = 2; + return; + } if (boundTo.length === 0) { // The submission is bound to nothing, so this version goes on it. const bound = await api("POST", "/v1/reviewSubmissionItems", JSON.stringify({ @@ -1339,13 +1370,52 @@ async function main() { if (!bound.ok) { for (const line of describeAssociatedErrors(bound)) blockers.push(line); console.error(`submit-review: ${candidate} could not be bound to ${versionId}: ${describeError(bound)}`); + // A rejected credential is not a recoverable error: the calls + // that follow this one include the submit, which cannot be undone, + // so the run stops here instead of issuing them with a credential + // App Store Connect has already refused. + if (bound.status === 401 || bound.status === 403) { + process.exitCode = 2; + return; + } continue; } console.error(`submit-review: bound ${versionId} to review submission ${candidate}`); } + // The platform is set on the candidate that is actually going to be + // submitted, and only now, after the binding was read and made. A + // submission that is skipped is never mutated. + if (candidatePlatform !== platform) { + const setPlatform = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ + data: { + type: "reviewSubmissions", + id: candidate, + attributes: { platform } + } + })); + if (!setPlatform.ok) { + console.error(`submit-review: the platform could not be set: ${describeError(setPlatform)}`); + process.exitCode = 2; + return; + } + // The platform is read back, so a change that was ignored or only + // half applied cannot slip into the call that cannot be undone. + const readBack = await api("GET", `/v1/reviewSubmissions/${candidate}`); + const applied = isPlainObject(readBack.parsed?.data?.attributes) + ? readBack.parsed.data.attributes.platform + : null; + if (applied !== platform) { + console.error( + `submit-review: the platform on ${candidate} is ${applied === null ? "unreadable" : applied}, ` + + `not ${platform}; not submitting` + ); + process.exitCode = 2; + return; + } + } // The one call that cannot be undone carries the one attribute that // has been proven in production, and nothing else. The platform was - // set when the submission was made. + // set above, on a call that can be undone. const result = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ data: { type: "reviewSubmissions", @@ -1354,7 +1424,18 @@ async function main() { } })); if (!result.ok) { + // The refusal on the submit carries the same blockers the binding + // refusal carries, and they are the most useful thing this command + // can print. + for (const line of describeAssociatedErrors(result)) blockers.push(line); console.error(`submit-review: ${candidate} refused: ${describeError(result)}`); + // A rejected credential stops the run here as well: the next + // candidate would be submitted with a credential that has already + // been refused. + if (result.status === 401 || result.status === 403) { + process.exitCode = 2; + return; + } continue; } submitted = candidate; @@ -1371,7 +1452,10 @@ async function main() { "cleared in App Store Connect before this one can go to review." ); } else { - console.error("submit-review: not submitting, no review submission is bound to this version"); + // The run got as far as the submit and App Store Connect refused + // it. Saying that no submission is bound would be false: this run + // bound the version itself. + console.error("submit-review: not submitting, App Store Connect refused the submission"); } process.exitCode = 2; return; diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 1858f998..14789343 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -43,13 +43,22 @@ const SUBMISSION_ID = "sub-1"; // submission has no items at all, which is the state a fresh account is // in and the one the command has to bind // bindFails: the binding call answers 409 and names the blockers +// bindAuthFailsOnce: the first binding call answers 401, the way a rejected +// credential does, and the calls after it succeed +// submitFails: the submit answers 409 and names the blockers // noSubmissions: the app has no review submission at all +// secondSubmission: the app has a second draft submission, so a run that +// does not stop at a rejected credential has somewhere to go +// submissionHasNoPlatform: the draft submission carries no platform at all // submissionPlatform: the platform the named submission answers with // platform: the platform the submission answers with function serve(options) { const patched = []; const bound = []; const created = []; + // The platform each submission carries. A platform that was set on it is + // remembered, so the read after the change answers with it. + const platforms = new Map(); // A submission that has been marked submitted reads back as one, and a // draft reads back as a draft. The read before the submit and the read // after it are the same call, so what changed in between is what tells @@ -68,14 +77,58 @@ function serve(options) { if (req.method === "PATCH" && url.startsWith("/v1/reviewSubmissions/")) { const body = JSON.parse(raw || "{}"); patched.push({ url, body }); - if (body?.data?.attributes?.submitted === true) { - sent.add(body.data.id); + const attributes = body?.data?.attributes ?? {}; + const id = body?.data?.id || SUBMISSION_ID; + const submitted = attributes.submitted === true; + if (submitted) { + sent.add(id); + } + if (typeof attributes.platform === "string") { + platforms.set(id, attributes.platform); + } + if (submitted && (options.submitFails || options.submitFailsPlain)) { + if (options.submitFailsPlain) { + // A refusal that names no blockers at all. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state." + }] }); + } + // The refusal the live service answers with when the version is + // not ready: the blockers are named, one entry per resource. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state.", + detail: "This resource cannot be reviewed, please check associated errors to see why.", + meta: { + associatedErrors: { + "/v1/appStoreVersionLocalizations/loc-1/placements": [{ + status: "409", + code: "STATE_ERROR.SCREENSHOT_REQUIRED.IPAD_13_PROFILE", + title: "App screenshot missing (IPAD_13_PROFILE).", + detail: "A screenshot for one of the following types is required but was not provided: IPAD_13_PROFILE" + }], + "/v1/appDataUsages/": [{ + status: "409", + code: "STATE_ERROR.APP_DATA_USAGES_REQUIRED", + title: "Answers to what data your app collects and how it's used are needed.", + detail: "You must have published answers to your app's data usages." + }] + } + } + }] }); } return json(200, { data: { type: "reviewSubmissions", - id: SUBMISSION_ID, - attributes: { state: "WAITING_FOR_REVIEW", submittedDate: "2026-10-20T00:00:00Z" } + id, + attributes: { + platform: platforms.get(id) || options.submissionPlatform || "IOS", + state: submitted ? "WAITING_FOR_REVIEW" : "READY_FOR_REVIEW", + submittedDate: submitted ? "2026-10-20T00:00:00Z" : null + } } }); } @@ -174,22 +227,68 @@ function serve(options) { if (options.noSubmissions) { return json(200, { data: [] }); } - return json(200, { data: [{ - id: SUBMISSION_ID, - type: "reviewSubmissions", - attributes: { platform: "IOS", state: "READY_FOR_REVIEW", submittedDate: null } - }] }); + const draft = (id) => { + const attributes = { state: "READY_FOR_REVIEW", submittedDate: null }; + if (!options.submissionHasNoPlatform) { + attributes.platform = options.submissionPlatform || "IOS"; + } + return { id, type: "reviewSubmissions", attributes }; + }; + const data = [draft(SUBMISSION_ID)]; + if (options.secondSubmission) { + data.push(draft("sub-2")); + } + return json(200, { data }); } if (req.method === "POST" && url === "/v1/reviewSubmissions") { - created.push(JSON.parse(raw || "{}")); + const body = JSON.parse(raw || "{}"); + created.push(body); + // The created submission carries the platform it was made with. + platforms.set("sub-new", body?.data?.attributes?.platform || "IOS"); return json(201, { data: { type: "reviewSubmissions", id: "sub-new", - attributes: { platform: "IOS", state: "READY_FOR_REVIEW", submittedDate: null } + attributes: { platform: body?.data?.attributes?.platform || "IOS", state: "READY_FOR_REVIEW", submittedDate: null } } }); } if (req.method === "POST" && url === "/v1/reviewSubmissionItems") { - bound.push(JSON.parse(raw || "{}")); + // The body is untrusted input, so it is parsed in a try block and + // validated before anything is stored. A body that is not JSON, or + // that does not name a review submission and a version, is refused + // with a 400 instead of throwing out of the handler. + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return json(400, { errors: [{ title: "the body is not JSON" }] }); + } + const data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.data : null; + const relationships = data && typeof data === "object" && !Array.isArray(data) ? data.relationships : null; + const submissionId = relationships?.reviewSubmission?.data?.id; + const versionId = relationships?.appStoreVersion?.data?.id; + if (typeof submissionId !== "string" || submissionId.length === 0 || + typeof versionId !== "string" || versionId.length === 0) { + return json(400, { errors: [{ title: "the body does not name a review submission and a version" }] }); + } + // Only the validated object is stored. + bound.push({ + data: { + type: "reviewSubmissionItems", + relationships: { + reviewSubmission: { data: { type: "reviewSubmissions", id: submissionId } }, + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } + } + } + }); + if (options.bindAuthFailsOnce && bound.length === 1) { + // The refusal a rejected credential gets. The calls after this + // one include the submit, which cannot be undone. + return json(401, { errors: [{ + status: "401", + code: "NOT_AUTHORIZED", + title: "Authentication credentials are missing or invalid." + }] }); + } if (options.bindFails) { // The refusal the live service answers with when the version is // not ready: the blockers are named, one entry per resource. @@ -252,7 +351,7 @@ function serve(options) { type: "reviewSubmissions", id, attributes: { - platform: options.submissionPlatform || "IOS", + platform: platforms.get(id) || options.submissionPlatform || "IOS", state: "READY_FOR_REVIEW", submittedDate: null } @@ -506,6 +605,144 @@ test("a ready version is submitted, and only the report is on stdout", async () }); }); +test("a rejected credential stops the run at the binding", async () => { + // A 401 on the binding is not a recoverable error: the calls after it + // include the submit, which cannot be undone, so the run has to stop with + // the credential App Store Connect has already refused. + await withServer({ ...READY, boundTo: undefined, bindAuthFailsOnce: true, secondSubmission: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /rejected the key/, `the rejection was not reported:\n${stderr}`); + assert.equal(bound.length, 1, "the run went on to the next candidate with a rejected credential"); + assert.equal(patched.length, 0, "a submission was sent with a rejected credential"); + }); +}); + +test("a submission that holds another version is never patched", async () => { + // The platform is set on the candidate that is actually going to be + // submitted, so a submission that is left alone is not mutated either. + await withServer({ ...READY, boundTo: "some-other-version", desktopScreens: 3 }, + async (base, patched) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /holds another version/, `the refusal did not say what has to be cleared:\n${stderr}`); + assert.equal(patched.length, 0, "a submission that was skipped was patched"); + }); +}); + +test("a named submission for another platform is refused when no platform was asked for", async () => { + // The default platform is this command's, not the operator's, so a + // submission somebody prepared for another platform is never rewritten to + // the default and submitted. + await withServer({ ...READY, submissionPlatform: "MAC_OS" }, async (base, patched, bound) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /is for MAC_OS/, `the refusal did not name the platform:\n${stderr}`); + assert.equal(patched.length, 0, "the submission was rewritten to the default platform"); + assert.equal(bound.length, 0, "the version was bound before the refusal"); + }); +}); + +test("the submit refusal reports the blockers App Store Connect named", async () => { + // The refusal on the submit carries the same blockers the binding refusal + // carries. Reporting that no submission is bound would be false: this + // run bound the version itself. + await withServer({ ...READY, boundTo: undefined, submitFails: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /STATE_ERROR\.SCREENSHOT_REQUIRED\.IPAD_13_PROFILE/.test(stderr), + `the refusal did not name the missing screenshot:\n${stderr}` + ); + assert.ok( + /STATE_ERROR\.APP_DATA_USAGES_REQUIRED/.test(stderr), + `the refusal did not name the missing privacy answers:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /no review submission is bound to this version/, + `the refusal said the version was unbound:\n${stderr}` + ); + assert.equal(patched.length, 1, "the submit was not attempted"); + }); +}); + +test("a submit refusal without blockers says that App Store Connect refused it", async () => { + // The run bound the version itself, so saying that no submission is bound + // to it would be false. + await withServer({ ...READY, boundTo: undefined, submitFailsPlain: true }, async (base) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /App Store Connect refused the submission/.test(stderr), + `the refusal did not say what App Store Connect answered:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /no review submission is bound to this version/, + `the refusal said the version was unbound:\n${stderr}` + ); + }); +}); + +test("an auto-discovered submission with no platform is given the platform before it is submitted", async () => { + // A submission whose platform attribute is absent would go to review with + // no platform at all, because the platform is never applied on that path. + // It is set, and read back, before the irreversible call. + await withServer({ ...READY, submissionHasNoPlatform: true }, async (base, patched) => { + const { code, stdout, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(patched.length, 2, `expected a platform patch and a submit patch, got ${patched.length}`); + assert.equal(patched[0].body?.data?.attributes?.platform, "IOS"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + assert.equal(patched[1].body?.data?.attributes?.submitted, true); + const report = JSON.parse(stdout.trim()); + assert.equal(report.reviewSubmission, SUBMISSION_ID); + }); +}); + +test("the mock refuses a binding body that names no submission and no version", async () => { + // The mock is a trust boundary too: an inbound body is validated before + // it is stored, and a malformed one is refused with a 400 instead of + // throwing out of the handler. + const { server, bound } = serve({ ...READY }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + // The request carries a timeout, so a handler that throws instead of + // answering is a failed assertion and not a hung test. + const post = (body) => new Promise((resolve) => { + const request = fetch(`${base}/v1/reviewSubmissionItems`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(2000) + }); + request.then((response) => resolve(response.status)).catch(() => resolve(0)); + }); + try { + assert.equal(await post("{not json"), 400, "a body that is not JSON was accepted"); + assert.equal(await post(JSON.stringify({ data: { relationships: {} } })), 400, + "a body with no ids was accepted"); + assert.equal(await post(JSON.stringify({ + data: { relationships: { reviewSubmission: { data: { id: 7 } }, appStoreVersion: { data: { id: "" } } } } + })), 400, "a body with ids that are not strings was accepted"); + assert.equal(bound.length, 0, "a refused body was stored"); + assert.equal(await post(JSON.stringify({ + data: { + relationships: { + reviewSubmission: { data: { id: SUBMISSION_ID } }, + appStoreVersion: { data: { id: VERSION_ID } } + } + } + })), 201, "a valid body was refused"); + assert.equal(bound.length, 1, "the validated body was not stored"); + assert.equal(bound[0].data.relationships.reviewSubmission.data.id, SUBMISSION_ID); + } finally { + server.close(); + } +}); + test("the platform can be chosen, and a bad one is refused", async () => { // A macOS version carries desktop screenshots, so it is submitted against // a server that serves them rather than the iPhone and iPad sets. From 3a26fd028c2a99ef7bc9bf4f2b5c7b66e8c73c7e Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 12:05:10 -0500 Subject: [PATCH 44/50] CC-169: name the extra allowance beside the window it doubles The owner-approved copy spec says that weekly and additional windows read weekly and additional beside the cadence token. The pair could not appear: an extra allowance carries no token of its own, and the label pipeline rewrites it into the same Other limit copy every unknown window gets, so neither the cadence nor the rendered label could identify it. The identity now travels with the window. QuotaWindowVocabulary.isExtraAllowance(providerKey:label:windowToken:) answers from the raw wire label and the provider key, and the provider-aware case is the Muse Assist one the Mac popover already treats as the extra allowance. barQualifiers(forBars:) then lets an extra allowance join the group of the one sibling that has a period, so the weekly and the extra resolve as a single cadence. With two different periods beside it the sibling is ambiguous, so the extra stays on its own and neither bar prints a qualifier. The widget carries the flag from the raw wire label and the provider key into the bar caption, because the display label erases the identity. The window's own cadence is untouched, so the seenCadenceKeys dedup cannot drop the extra bar and the plus token is preserved. The regression fixture is the real wire shape with the identifying strings sanitized, in Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift, because the iOS target has no unit-test target. Refs CC-169 --- Sources/QuotaCore/QuotaWindowVocabulary.swift | 84 +++++++++- .../WidgetPresentationVocabularyTests.swift | 158 ++++++++++++++++++ .../Widgets/WidgetPresentation.swift | 31 +++- 3 files changed, 262 insertions(+), 11 deletions(-) diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index fcef91cc..edafdf8d 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -42,10 +42,16 @@ public enum QuotaWindowVocabulary { public struct BarCaption: Equatable, Sendable { public let cadence: String public let label: String + /// True when the window is the extra allowance of another window on the + /// same plan. The label alone cannot answer that: the label pipeline + /// rewrites an extra allowance into the same "Other limit" copy every + /// unknown window gets, so the identity has to travel with the bar. + public let isExtraAllowance: Bool - public init(cadence: String, label: String) { + public init(cadence: String, label: String, isExtraAllowance: Bool = false) { self.cadence = cadence self.label = label + self.isExtraAllowance = isExtraAllowance } } @@ -76,9 +82,19 @@ public enum QuotaWindowVocabulary { /// /// The label is consulted for the extra-allowance case alone, which is /// the one qualifier the label still names. - public static func barQualifier(forCadence cadence: String, label: String, sharesCadence: Bool) -> String { + /// + /// `isExtraAllowance` carries the identity the label cannot: an extra + /// allowance is rewritten into the same "Other limit" copy every unknown + /// window gets, so a bar that is known to be the extra allowance has to + /// say so, or it is read as an ordinary window of its period. + public static func barQualifier( + forCadence cadence: String, + label: String, + sharesCadence: Bool, + isExtraAllowance: Bool = false + ) -> String { guard sharesCadence else { return "" } - if isExtraAllowanceLabel(label) { return barQualifierAdditional } + if isExtraAllowance || isExtraAllowanceLabel(label) { return barQualifierAdditional } switch cadence { case sevenDay: return barQualifierWeekly case oneMonth: return barQualifierMonthly @@ -99,10 +115,15 @@ public enum QuotaWindowVocabulary { /// groups with that window rather than standing alone. It carries no /// cadence of its own, so its label is left out of the lookup rather /// than collapsing the row to "Other limit". - public static func barCadenceGroup(cadence: String, label: String) -> String { + public static func barCadenceGroup( + cadence: String, + label: String, + isExtraAllowance: Bool = false + ) -> String { + let extra = isExtraAllowance || isExtraAllowanceLabel(label) let canonical = standardCadence( windowToken: cadence, - label: isExtraAllowanceLabel(label) ? "" : label + label: extra ? "" : label ) if canonical != otherLimit { return canonical } let tokens = lexicalTokens(label) @@ -118,14 +139,40 @@ public enum QuotaWindowVocabulary { /// Each bar is resolved against its siblings, so a bar that shares its /// cadence with another bar gets the word that tells the two apart and a /// lone bar gets none. + /// + /// An extra allowance has no period of its own: it is extra capacity of + /// the period it doubles, and a provider that reports it without a token + /// names no period at all. When its own cadence resolves to nothing, it + /// joins the group of the one sibling that has a period, which is what + /// makes the owner-approved pair ("weekly" / "additional") reachable. + /// The sibling has to be unambiguous, so a platform with two different + /// periods beside it leaves the extra allowance on its own. public static func barQualifiers(forBars bars: [BarCaption]) -> [String] { - let groups = bars.map { barCadenceGroup(cadence: $0.cadence, label: $0.label) } + var groups = bars.map { + barCadenceGroup( + cadence: $0.cadence, + label: $0.label, + isExtraAllowance: $0.isExtraAllowance + ) + } + for index in bars.indices where bars[index].isExtraAllowance && groups[index] == otherLimit { + let siblingGroups = Set( + bars.indices + .filter { $0 != index } + .map { groups[$0] } + .filter { $0 != otherLimit } + ) + if siblingGroups.count == 1, let adopted = siblingGroups.first { + groups[index] = adopted + } + } return bars.indices.map { index in let siblings = groups.indices.filter { $0 != index }.map { groups[$0] } return barQualifier( forCadence: groups[index], label: bars[index].label, - sharesCadence: siblings.contains(groups[index]) + sharesCadence: siblings.contains(groups[index]), + isExtraAllowance: bars[index].isExtraAllowance ) } } @@ -257,6 +304,29 @@ public enum QuotaWindowVocabulary { || lower.hasSuffix(" extra") } + /// Whether a window is the extra allowance of another window on the same + /// plan. + /// + /// The label detector answers for labels that name the kind ("Additional + /// allowance", "+ extra"). A provider that files its extra allowance + /// without naming it is caught by the provider-aware check: Muse Assist + /// reports it as a tokenless window that resolves to no period, and the + /// Mac popover already treats exactly that window as the extra allowance + /// (`isMuseAssistExtra`). + /// + /// The identity has to be carried with the window rather than read back + /// from its label, because the label pipeline rewrites an extra allowance + /// into the same "Other limit" copy every unknown window gets. + public static func isExtraAllowance( + providerKey: String?, + label: String, + windowToken: String? = nil + ) -> Bool { + if isExtraAllowanceLabel(label) { return true } + guard let providerKey, !providerKey.isEmpty else { return false } + return isMuseAssistExtra(providerKey: providerKey, windowToken: windowToken, label: label) + } + public static func isOpaqueProviderKey(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index b1373e13..afa75949 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -284,4 +284,162 @@ final class WidgetPresentationVocabularyTests: XCTestCase { QuotaWindowVocabulary.sevenDay ) } + + /// A sanitized Muse Assist wire payload, as the reader files it today. + /// + /// The weekly limit arrives with a "weekly" token. The extra allowance + /// arrives with no token at all and with a label the pipeline has already + /// rewritten to the catch-all "Other limit" copy, so nothing in the label + /// says what kind of window it is. The provider and the missing token are + /// the whole of the signal, which is why the identity has to be carried + /// with the window instead of read back from its label. + private struct MuseAssistWireWindow { + let id: String + let label: String + let window: String? + let providerKey: String + + /// The canonical cadence the widget gives this window. + var canonicalCadence: String { + QuotaWindowVocabulary.standardCadence(windowToken: window, label: label) + } + + /// The label the widget renders for this window. + var displayLabel: String { + QuotaWindowVocabulary.windowLabel(windowToken: window, existingLabel: label) + } + + /// The bar facts the widget's qualifier resolution sees. + var barCaption: QuotaWindowVocabulary.BarCaption { + QuotaWindowVocabulary.BarCaption( + cadence: canonicalCadence, + label: displayLabel, + isExtraAllowance: QuotaWindowVocabulary.isExtraAllowance( + providerKey: providerKey, + label: label, + windowToken: window + ) + ) + } + } + + private let museAssistWeekly = MuseAssistWireWindow( + id: "weekly", + label: "weekly window", + window: "weekly", + providerKey: "muse-assist" + ) + + private let museAssistExtra = MuseAssistWireWindow( + id: "extra", + label: "Other limit window", + window: nil, + providerKey: "muse-assist" + ) + + /// The extra-allowance identity comes from the provider and the wire + /// label, because the rendered label cannot answer it. + func testExtraAllowanceIdentityIsReadFromTheWireNotTheDisplayLabel() { + XCTAssertTrue( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "muse-assist", + label: "Other limit window", + windowToken: nil + ), + "a tokenless Muse Assist window that names no period is the extra allowance." + ) + XCTAssertFalse( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "muse-assist", + label: "8h window", + windowToken: "8h" + ), + "a Muse Assist window with a real token is a meter, not extra usage." + ) + XCTAssertFalse( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "anthropic", + label: "Other limit window", + windowToken: nil + ), + "another provider's unknown window is not the Muse Assist extra allowance." + ) + // A label that names the kind is an extra allowance for any provider. + XCTAssertTrue( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "anthropic", + label: "Additional allowance", + windowToken: "7d" + ) + ) + } + + /// The owner-approved pair, driven by the real Muse Assist wire shapes. + /// + /// The extra allowance names no period of its own, so it cannot join the + /// weekly's group by itself and the pair never appeared: the weekly bar + /// read no qualifier and the extra bar read none either. It is extra + /// capacity of the period it doubles, so it joins the one sibling that has + /// a period, and the two bars read "weekly" and "additional" (owner, + /// 2026-10-21). + func testMuseAssistExtraAllowanceReadsAdditionalBesideTheWeekly() { + let bars = [museAssistWeekly, museAssistExtra] + + // The wire shapes are what the reader files: the weekly carries a + // token, the extra carries none and its label is the catch-all copy. + XCTAssertEqual(museAssistWeekly.canonicalCadence, QuotaWindowVocabulary.sevenDay) + XCTAssertEqual(museAssistWeekly.displayLabel, "7-day window") + XCTAssertEqual(museAssistExtra.canonicalCadence, QuotaWindowVocabulary.otherLimit) + XCTAssertEqual(museAssistExtra.displayLabel, "Other limit window") + + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: bars.map(\.barCaption)) + XCTAssertEqual(qualifiers, [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierAdditional + ]) + + // The caption row is the token beside the qualifier, and the extra + // allowance keeps the "+" marker the owner uses for extra quota. + let weeklyToken = QuotaWindowVocabulary.popoverCaption( + windowToken: museAssistWeekly.canonicalCadence, + label: museAssistWeekly.displayLabel + ) + let extraToken = QuotaWindowVocabulary.popoverCaption( + windowToken: museAssistExtra.canonicalCadence, + label: museAssistExtra.displayLabel + ) + XCTAssertEqual(weeklyToken, "7d") + XCTAssertEqual(extraToken, "+") + XCTAssertEqual("\(weeklyToken) \(qualifiers[0])", "7d weekly") + XCTAssertEqual("\(extraToken) \(qualifiers[1])", "+ additional") + } + + /// The sibling has to be unambiguous: with two different periods beside + /// it, an extra allowance cannot know which one it doubles, so it stays on + /// its own and neither bar prints a qualifier. + func testExtraAllowanceDoesNotJoinWhenItsSiblingsDisagree() { + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.BarCaption(cadence: "5-hour", label: "5-hour window"), + QuotaWindowVocabulary.BarCaption( + cadence: QuotaWindowVocabulary.otherLimit, + label: "Other limit window", + isExtraAllowance: true + ) + ]) + XCTAssertEqual(qualifiers, ["", "", ""]) + } + + /// An extra allowance on its own still prints no qualifier: there is no + /// sibling to tell it apart from. + func testLoneExtraAllowancePrintsNoQualifier() { + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption( + cadence: QuotaWindowVocabulary.otherLimit, + label: "Other limit window", + isExtraAllowance: true + ) + ]) + XCTAssertEqual(qualifiers, [""]) + } } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index 058f4dd6..66e9593c 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -14,6 +14,11 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { public let isExhausted: Bool public let isMasked: Bool public let observedAt: Date? + /// True when the window is the extra allowance of another window on the + /// same plan. The label cannot answer this: an extra allowance is + /// rewritten into the same "Other limit" copy every unknown window gets, + /// so the identity travels with the window. + public let isExtraAllowance: Bool /// Short distinguishing label for a bar when two bars share one cadence /// token (e.g. two "7d" bars). "Free weekly limit" reads "weekly", /// "Additional tokens" reads "additional". Empty when the row needs no @@ -40,7 +45,8 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { isExhausted: Bool = false, isMasked: Bool = false, observedAt: Date? = nil, - barQualifier: String = "" + barQualifier: String = "", + isExtraAllowance: Bool = false ) { self.id = id self.label = label @@ -51,6 +57,7 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { self.isMasked = isMasked self.observedAt = observedAt self.barQualifier = barQualifier + self.isExtraAllowance = isExtraAllowance } private static let staleAfter: TimeInterval = 30 * 60 @@ -89,7 +96,8 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { isExhausted: isExhausted, isMasked: isMasked, observedAt: observedAt, - barQualifier: qualifier + barQualifier: qualifier, + isExtraAllowance: isExtraAllowance ) } @@ -172,7 +180,11 @@ public struct WidgetPlatformItem: Identifiable, Equatable, Sendable { guard windows.count > 1 else { return windows } let qualifiers = QuotaWindowVocabulary.barQualifiers( forBars: windows.map { - QuotaWindowVocabulary.BarCaption(cadence: $0.cadence, label: $0.label) + QuotaWindowVocabulary.BarCaption( + cadence: $0.cadence, + label: $0.label, + isExtraAllowance: $0.isExtraAllowance + ) } ) return windows.indices.map { index in @@ -627,6 +639,16 @@ public enum WidgetPresentation { existingLabel: w.label.isEmpty ? cadence : w.label, resetAt: parsedReset ) + // The extra-allowance identity is read off the wire label, not + // off the display label: the display copy rewrites an extra + // allowance into the same "Other limit" text every unknown + // window gets, so by the time the qualifier is resolved the + // difference is gone. + let isExtraAllowance = QuotaWindowVocabulary.isExtraAllowance( + providerKey: providerKey, + label: w.label, + windowToken: w.window + ) let cadenceKey = cadence.lowercased() if !seenCadenceKeys.contains(cadenceKey) { @@ -639,7 +661,8 @@ public enum WidgetPresentation { resetAt: parsedReset, isExhausted: exhausted, isMasked: false, - observedAt: observed + observedAt: observed, + isExtraAllowance: isExtraAllowance )) } } From 8a8efa8b0fe098db8c1cce10f203f8382f47f8ad Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:07:08 -0500 Subject: [PATCH 45/50] CC-169: read a cadence out of the cadence field, not out of the label The cadence group handed the window's label to the canonicalizer, and the canonicalizer's prose matcher reads substrings. A label that merely contained a cadence word was therefore read as that window: "notweekly" grouped as a weekly bar and "notdaily" as a daily one, both before the exact-token fallback could run. The cadence field is now the only thing that may name a group, and the label resolves a group only through whole lexical tokens. The widget builds the cadence field and the display label from the same canonicalization, so the label never carried a period the cadence field had lost. Kody rule 40 (avoid substring matching for canonical limit/window labels), binding 2026-10-21. --- Sources/QuotaCore/QuotaWindowVocabulary.swift | 15 +++++--- .../WidgetPresentationVocabularyTests.swift | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index edafdf8d..2feca406 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -107,9 +107,13 @@ public enum QuotaWindowVocabulary { /// The canonical cadence a window belongs to, used to group the bars that /// share one period. /// - /// The window's own cadence wins, because that is what the caption token - /// beside the bar prints; the label is only consulted when the cadence - /// field cannot be resolved on its own (Kody review 2026-10-21). + /// The window's own cadence decides the group, because that is what the + /// caption token beside the bar prints. The label is deliberately not + /// handed to the canonicalizer: the prose matcher behind it reads + /// substrings, so a label such as "notweekly" or "notdaily" was read as a + /// weekly or daily window before the exact-token fallback below ever ran + /// (Kody rule 40, binding 2026-10-21). A label resolves a group only + /// through whole lexical tokens, below. /// /// An extra allowance is extra capacity of the period it doubles, so it /// groups with that window rather than standing alone. It carries no @@ -120,12 +124,13 @@ public enum QuotaWindowVocabulary { label: String, isExtraAllowance: Bool = false ) -> String { - let extra = isExtraAllowance || isExtraAllowanceLabel(label) let canonical = standardCadence( windowToken: cadence, - label: extra ? "" : label + label: "" ) if canonical != otherLimit { return canonical } + // The label resolves a group only through whole lexical tokens: + // "weekly" names the weekly group, "notweekly" names no group at all. let tokens = lexicalTokens(label) if tokens.contains("weekly") { return sevenDay } if tokens.contains("monthly") || tokens.contains("billing") { return oneMonth } diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index afa75949..0ac55cf7 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -146,8 +146,8 @@ final class WidgetPresentationVocabularyTests: XCTestCase { } /// Kody rule "Avoid Substring Matching for Canonical Limit/Window Labels" - /// (binding, 2026-10-21): the prose fallback in the cadence group reads - /// whole lexical tokens, so "nothourly" is not an hourly window. + /// (binding, 2026-10-21): the cadence group reads whole lexical tokens, + /// so "nothourly" is not an hourly window. func testBarCadenceGroupFallbackMatchesExactTokensNotSubstrings() { XCTAssertEqual( QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "nothourly"), @@ -159,6 +159,40 @@ final class WidgetPresentationVocabularyTests: XCTestCase { ) } + /// A label that merely contains a cadence word named that window. + /// + /// The canonicalizer reads the label as prose, and prose matching is + /// substring matching, so "notweekly" was a weekly bar and "notdaily" was a + /// daily one (Kody rule 40, binding 2026-10-21). The cadence field is + /// now the only thing that may name a group, and the label resolves a + /// group only through whole tokens. + func testBarCadenceGroupDoesNotReadACadenceWordOutOfTheLabel() { + for label in ["notweekly", "unweeklyplan", "notdaily", "notbilling", "notinterval", "notcycle", "not1d"] { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: label), + QuotaWindowVocabulary.otherLimit, + "the label '\(label)' names no window and must not resolve a group." + ) + } + } + + /// A label that names the period outright still resolves the group, + /// because the fallback matches whole tokens. + func testBarCadenceGroupStillResolvesALabelThatNamesThePeriod() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "Weekly cap"), + QuotaWindowVocabulary.sevenDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "Billing cycle"), + QuotaWindowVocabulary.oneMonth + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "Daily cap"), + QuotaWindowVocabulary.oneDay + ) + } + /// The canonical labels the vocabulary produces must resolve back to /// themselves when they are handed to the cadence lookup as a token. /// From 371d11e37abc0121f208d516ff95858b77aab8c6 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:03:45 -0500 Subject: [PATCH 46/50] fix(release): take the App Store Connect key from the environment only vFp: a refused submit no longer binds the version a second time. The run records the submission it bound and stops at the refusal, because one version lives on one submission and the binding cannot be undone from the tool. u8i, u4pxt, vS4, u4qJe: the private key is read from the environment or from fleet-secrets only. asc-key.mjs lost its file reader, ios-enable-apns.mjs takes the key from process.env.ASC_KEY_P8, and ship-testflight.sh and ios-install-appstore-profiles.sh ignore a named ASC_KEY_PATH, which is not a credential source at all. vAo, audit defect 3: a child is given an allowlist of variables instead of a copy of the parent's environment, so an unrelated credential never reaches the App Store Connect client. vJZ, audit defect 2: the process table is read on an explicit readiness signal, and the check sees the environment of the key-bearing child, which is where the key lives on this platform. The comments now say what is true: the key is not in argv, not on stdout and not in a log line, and the environment of a same-user process is readable. vN_, u4qVz: the mock validates the create and patch bodies before it stores them, and a malformed body is refused with a 400 instead of throwing out of the handler. u4p6p: a create response that does not echo a platform records none, so the platform is set and read back instead of being assumed. u4qAV: the platform is set and read back before the binding, so a platform that cannot be proven leaves the version unbound and nothing to clear by hand. u4p2K: the final report tells a refusal from a run that never submitted, so the message never claims that a submission was refused when none was sent. finding 12: the mock can answer a draft with no platform, and the read-back guard is covered by tests. --- scripts/ios-enable-apns.mjs | 77 ++-- scripts/ios-enable-apns.test.mjs | 44 ++- scripts/ios-fleet/asc-api.mjs | 131 +++++-- scripts/ios-fleet/asc-key.mjs | 202 ++++------ scripts/ios-fleet/ship-testflight.sh | 113 ++---- scripts/ios-fleet/test-asc-credentials.sh | 187 +++++++-- scripts/ios-fleet/test-asc-key.test.mjs | 437 +++++++++++++--------- scripts/ios-fleet/test-asc-submit.mjs | 371 ++++++++++++++++-- scripts/ios-install-appstore-profiles.sh | 48 ++- 9 files changed, 1082 insertions(+), 528 deletions(-) diff --git a/scripts/ios-enable-apns.mjs b/scripts/ios-enable-apns.mjs index f538d172..f62d6ce5 100644 --- a/scripts/ios-enable-apns.mjs +++ b/scripts/ios-enable-apns.mjs @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; -import { readPrivateKeyFile } from './ios-fleet/asc-key.mjs'; +import { clientEnvironment, normalizePrivateKey } from './ios-fleet/asc-key.mjs'; export const bundleIdentifier = 'com.simplewithus.codecaps.ios'; export const pushProfileName = 'CodeCaps Companion AppStore'; @@ -168,31 +168,54 @@ function inspectProductionProfile(encoded) { && xml.includes(`.${bundleIdentifier}`); } -// The App Store Connect client takes the key from the environment, because a -// path on disk is not an approved secret source (Kody rule 42). The hosted -// runner stages the key to a mode-600 file and publishes only its path, so a -// staged key is read here, with the hardened reader, and handed to the child -// in the child's environment. +// The credential the App Store Connect client runs with. // -// The key never reaches a command line, a log, the environment file, or a -// temporary file: the reader keeps it in the file, this function keeps it in -// the process, and only the child process receives it. +// The client resolves its credential from the process environment, from +// fleet-secrets and from the bootstrap Keychain. A path on disk is not an +// approved secret source (Kody rule 42), so this wrapper never reads one: +// ASC_KEY_PATH is not a credential source here, and the key is taken from +// the environment and handed to the child in the child's environment. // -// A path that names nothing is not a credential: the run falls through and -// the client resolves the credential the way it does today, from an exported -// ASC_KEY_P8 or from fleet-secrets. -export function stagedKeyEnvironment(env = process.env) { - const keyPath = env.ASC_KEY_PATH; - if (!keyPath) return {}; - const read = readPrivateKeyFile(keyPath); - return read.pem ? { ASC_KEY_P8: read.pem } : {}; +// The key never reaches a command line, a log, the environment file or a +// temporary file. It does reach the child's environment, where the client +// looks for it, and the environment of a same-user process is readable on +// this platform (`ps eww`, `ps -E`): the handoff keeps the key out of argv, +// out of stdout and out of every log line, and claims nothing more. +// +// A key that is set but is not a PEM private key is refused here, with the +// variable named. A silent drop would leave the child without a credential +// and the operator with a permission message that says nothing about the +// input that was wrong. +export function credentialEnvironment(env = process.env) { + const raw = typeof env.ASC_KEY_P8 === 'string' ? env.ASC_KEY_P8 : ''; + if (!raw.trim()) return {}; + const pem = normalizePrivateKey(raw); + if (!pem) throw new Error('ASC_KEY_P8 is set but does not hold a PEM private key'); + return { ASC_KEY_P8: pem }; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +// The child's environment. +// +// It is an allowlist, not a copy of this process's environment: a wholesale +// copy forwards every credential on the operator's machine, including the +// ones that have nothing to do with App Store Connect. +export function childEnvironment(env = process.env) { + return clientEnvironment(env, credentialEnvironment(env)); +} + +async function run() { const client = fileURLToPath(new URL('./ios-fleet/asc-api.mjs', import.meta.url)); - // The child gets the staged key in its environment, so the credential - // survives the hop without the client ever reading a file. - const childEnv = { ...process.env, ...stagedKeyEnvironment() }; + // The child gets the credential and the variables it needs, and nothing + // else. The credential is in the child's environment because that is where + // the client looks for it. + let childEnv; + try { + childEnv = childEnvironment(); + } catch (error) { + console.error(`ios-enable-apns: ${error.message}`); + process.exitCode = 1; + return; + } const request = async (method, path, body) => { const args = [client, method, path]; if (body) args.push(JSON.stringify(body)); @@ -201,6 +224,11 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024, env: childEnv })); } catch (error) { + // The client's own diagnostic names what it could not resolve, so it + // is carried over instead of being replaced by a generic permission + // message that points at nothing. + const stderrText = error.stderr?.toString() ?? ''; + const auth = stderrText.split('\n').filter(line => line.startsWith('asc-auth:')).join(' '); let codes = ''; try { const failure = JSON.parse(error.stdout?.toString() ?? '{}'); @@ -210,7 +238,8 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur return `${item.code}: ${item.title}`; }).filter(Boolean).join('; '); } catch { /* Keep profile contents and credential material out of errors. */ } - throw new Error(`Apple provisioning request failed: ${method} ${path.split('?')[0]}.${codes ? ' ' + codes : ' Check ASC credential access and API permissions.'}`); + const reason = auth || codes || 'Check ASC credential access and API permissions.'; + throw new Error(`Apple provisioning request failed: ${method} ${path.split('?')[0]}. ${reason}`); } }; const checkOnly = process.argv.includes('--check'); @@ -219,3 +248,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur else console.log(`CodeCaps Push Notifications enabled; verified profile: ${result.profile}.`); }).catch(error => { console.error(error.message); process.exitCode = 1; }); } + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + run(); +} diff --git a/scripts/ios-enable-apns.test.mjs b/scripts/ios-enable-apns.test.mjs index cdbf5bdb..cc21522c 100644 --- a/scripts/ios-enable-apns.test.mjs +++ b/scripts/ios-enable-apns.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { provisionPush, pushProfileName } from './ios-enable-apns.mjs'; +import { generateKeyPairSync } from 'node:crypto'; +import { childEnvironment, credentialEnvironment, provisionPush, pushProfileName } from './ios-enable-apns.mjs'; function profile(id, { name = pushProfileName, state = 'INVALID', content = 'production-profile' } = {}) { return { id, attributes: { name, profileState: state, profileType: 'IOS_APP_STORE', profileContent: content } }; @@ -144,3 +145,44 @@ test('an invalid replacement profile is rejected rather than accepted as ready', await assert.rejects(provisionPush(request, () => false), /preserve it and do not create another/); assert.equal(f.writes.filter(write => write.path === '/v1/profiles').length, 1); }); + +// The credential the consumer hands to the App Store Connect client. +// +// The private key is a secret, and the approved sources are the process +// environment, a captured `fleet-secrets get NAME` and bootstrap-only +// Keychain credentials. A caller-supplied file is not one of them, so +// nothing here reads a key file. + +test('the credential comes from the environment, and a path is not a source', () => { + // A throwaway key, generated here and never committed. + const key = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + .export({ type: 'pkcs8', format: 'pem' }); + const environment = credentialEnvironment({ ASC_KEY_P8: key }); + assert.deepEqual(Object.keys(environment), ['ASC_KEY_P8']); + // A path on disk is not an approved source, so it is not read. + assert.deepEqual(credentialEnvironment({ ASC_KEY_PATH: '/tmp/whatever.p8' }), {}); + // Nothing in the environment means nothing to hand over: the client + // resolves the credential from fleet-secrets on its own. + assert.deepEqual(credentialEnvironment({}), {}); +}); + +test('a key that is set but is not a key is refused with the variable named', () => { + // A silent drop would leave the child without a credential and the + // operator with a permission message that points at nothing. + assert.throws( + () => credentialEnvironment({ ASC_KEY_P8: 'not a private key at all' }), + /ASC_KEY_P8 is set but does not hold a PEM private key/ + ); +}); + +test('the child environment is an allowlist, not a copy of the parent environment', () => { + const child = childEnvironment({ + PATH: '/usr/bin', + HOME: '/tmp/home', + ASC_KEY_ID: 'SYNTHETICKEYID1', + ASC_ISSUER_ID: 'synthetic-issuer', + UNRELATED_RUNTIME_SECRET: 'unrelated-runtime-secret-4f1c9a' + }); + assert.equal(child.UNRELATED_RUNTIME_SECRET, undefined, 'an unrelated secret reached the child'); + assert.deepEqual(Object.keys(child).sort(), ['ASC_ISSUER_ID', 'ASC_KEY_ID', 'HOME', 'PATH']); +}); diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index 2fc2967e..ad2485f5 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -1302,11 +1302,14 @@ async function main() { } candidates = [createdId]; // The platform went on it at create time, so it does not have to be - // set again. + // set again. A response that does not echo a platform is recorded as + // carrying none: recording the platform that was asked for would make + // the platform check pass, and the submission would go to review with a + // platform that was never proven. const createdPlatform = isPlainObject(created.parsed?.data?.attributes) ? created.parsed.data.attributes.platform : null; - candidatePlatforms.set(createdId, typeof createdPlatform === "string" ? createdPlatform : platform); + candidatePlatforms.set(createdId, typeof createdPlatform === "string" ? createdPlatform : null); } // Is this submission bound to the version? @@ -1326,7 +1329,23 @@ async function main() { }; let submitted = null; + // Whether the submit was ever attempted. A refusal of the submit and a + // run that never sent one are different failures, and the final report + // has to say which one happened. + let submitAttempted = false; const boundElsewhere = []; + // The submission this run bound the version to. A binding cannot be + // undone from this tool, and one version lives on one submission, so + // every path that leaves the version bound and the submission unsent + // says so and stops the run. + let boundSubmission = null; + const reportBound = () => { + console.error( + `submit-review: the version ${versionId} is bound to review submission ${boundSubmission}, ` + + "which was not submitted. The binding cannot be undone from here: clear it in App Store Connect " + + "before the next run." + ); + }; for (const candidate of candidates) { const items = await api("GET", `/v1/reviewSubmissions/${candidate}/items`); if (!items.ok) { @@ -1344,7 +1363,9 @@ async function main() { } // The platform is checked before anything is written: a submission // that carries another platform is never rewritten to one the operator - // did not ask for, and a refusal mutates nothing. + // did not ask for, and this refusal mutates nothing. The platform is + // set and read back below, before the binding, so that a platform + // that cannot be proven stops the run with the version still unbound. const candidatePlatform = candidatePlatforms.has(candidate) ? candidatePlatforms.get(candidate) : null; @@ -1356,35 +1377,11 @@ async function main() { process.exitCode = 2; return; } - if (boundTo.length === 0) { - // The submission is bound to nothing, so this version goes on it. - const bound = await api("POST", "/v1/reviewSubmissionItems", JSON.stringify({ - data: { - type: "reviewSubmissionItems", - relationships: { - reviewSubmission: { data: { type: "reviewSubmissions", id: candidate } }, - appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } - } - } - })); - if (!bound.ok) { - for (const line of describeAssociatedErrors(bound)) blockers.push(line); - console.error(`submit-review: ${candidate} could not be bound to ${versionId}: ${describeError(bound)}`); - // A rejected credential is not a recoverable error: the calls - // that follow this one include the submit, which cannot be undone, - // so the run stops here instead of issuing them with a credential - // App Store Connect has already refused. - if (bound.status === 401 || bound.status === 403) { - process.exitCode = 2; - return; - } - continue; - } - console.error(`submit-review: bound ${versionId} to review submission ${candidate}`); - } - // The platform is set on the candidate that is actually going to be - // submitted, and only now, after the binding was read and made. A - // submission that is skipped is never mutated. + // The platform is set and read back BEFORE the binding, so the + // platform is proven before any write that cannot be undone. A + // platform that cannot be set, or that is not there when it is read + // back, stops the run with the version still unbound, so there is + // nothing for the operator to clear by hand. if (candidatePlatform !== platform) { const setPlatform = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ data: { @@ -1395,6 +1392,9 @@ async function main() { })); if (!setPlatform.ok) { console.error(`submit-review: the platform could not be set: ${describeError(setPlatform)}`); + // The binding has not happened yet, so the version is on no + // submission and nothing has to be cleared by hand. + console.error("submit-review: the version was not bound and nothing was submitted"); process.exitCode = 2; return; } @@ -1406,16 +1406,50 @@ async function main() { : null; if (applied !== platform) { console.error( - `submit-review: the platform on ${candidate} is ${applied === null ? "unreadable" : applied}, ` + + `submit-review: the platform on ${candidate} is ` + + `${typeof applied === "string" ? applied : "unreadable"}, ` + `not ${platform}; not submitting` ); + // The binding has not happened yet, so the version is on no + // submission and nothing has to be cleared by hand. + console.error("submit-review: the version was not bound and nothing was submitted"); process.exitCode = 2; return; } } + if (boundTo.length === 0) { + // The submission is bound to nothing, so this version goes on it. + const bound = await api("POST", "/v1/reviewSubmissionItems", JSON.stringify({ + data: { + type: "reviewSubmissionItems", + relationships: { + reviewSubmission: { data: { type: "reviewSubmissions", id: candidate } }, + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } + } + } + })); + if (!bound.ok) { + for (const line of describeAssociatedErrors(bound)) blockers.push(line); + console.error(`submit-review: ${candidate} could not be bound to ${versionId}: ${describeError(bound)}`); + // A rejected credential is not a recoverable error: the calls + // that follow this one include the submit, which cannot be undone, + // so the run stops here instead of issuing them with a credential + // App Store Connect has already refused. + if (bound.status === 401 || bound.status === 403) { + process.exitCode = 2; + return; + } + continue; + } + // The version is bound to this submission now, and the binding + // cannot be undone from here. + boundSubmission = candidate; + console.error(`submit-review: bound ${versionId} to review submission ${candidate}`); + } // The one call that cannot be undone carries the one attribute that // has been proven in production, and nothing else. The platform was // set above, on a call that can be undone. + submitAttempted = true; const result = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ data: { type: "reviewSubmissions", @@ -1433,29 +1467,44 @@ async function main() { // candidate would be submitted with a credential that has already // been refused. if (result.status === 401 || result.status === 403) { + if (boundSubmission) reportBound(); process.exitCode = 2; return; } + // This run bound the version, so the run stops here. A refused + // submit leaves the version attached to a submission, and a second + // candidate would be bound to the same version: one version lives + // on one submission, and the binding cannot be undone from here. + if (boundSubmission) { + reportBound(); + break; + } continue; } submitted = candidate; break; } if (!submitted) { + if (submitAttempted) { + // The run got as far as the submit and App Store Connect refused + // it. Saying that no submission is bound would be false: this run + // bound the version itself. + console.error("submit-review: not submitting, App Store Connect refused the submission"); + } else { + // The run never got as far as the submit, so no submission carries + // this version. Saying that App Store Connect refused a submission + // would be false: none was sent. + console.error("submit-review: not submitting, the version was never bound to a submission"); + } if (blockers.length > 0) { - console.error("submit-review: not submitting, App Store Connect says the version is not ready:"); + console.error("submit-review: App Store Connect says the version is not ready:"); for (const line of blockers) console.error(` ${line}`); - } else if (boundElsewhere.length > 0) { + } else if (!submitAttempted && boundElsewhere.length > 0) { console.error( - "submit-review: not submitting, the app's review submission holds another version " + + "submit-review: the app's review submission holds another version " + `(${boundElsewhere.join("; ")}). One submission carries one version, so it has to be ` + "cleared in App Store Connect before this one can go to review." ); - } else { - // The run got as far as the submit and App Store Connect refused - // it. Saying that no submission is bound would be false: this run - // bound the version itself. - console.error("submit-review: not submitting, App Store Connect refused the submission"); } process.exitCode = 2; return; diff --git a/scripts/ios-fleet/asc-key.mjs b/scripts/ios-fleet/asc-key.mjs index 5a519643..b16e2517 100644 --- a/scripts/ios-fleet/asc-key.mjs +++ b/scripts/ios-fleet/asc-key.mjs @@ -1,45 +1,42 @@ #!/usr/bin/env node /** - * The App Store Connect private key: how it is validated, and how a staged - * key file is read. + * The App Store Connect private key: how it is validated, and how it is + * staged for the Apple tools that need a key file. * * WHY THIS IS ITS OWN MODULE (2026-10-21, Kody rule 42 on PR #271): * The private key is a secret, and the fleet's approved secret sources are the * process environment, a captured `fleet-secrets get NAME`, and bootstrap-only * Keychain credentials. A caller-supplied local file is not one of them, so - * the node client never reads a key file. The shell layer that already owns - * the credential reads the staged key and hands it to the client through the - * environment; this module is the part of that handoff the shell needs. + * nothing here reads a key file: the only approved sources of the key are + * process.env.ASC_KEY_P8 and `fleet-secrets get ASC_KEY_P8`. * - * The file read opens the path ONCE with O_NOFOLLOW and inspects and reads the - * descriptor it got, so a path that is swapped for a symlink between the check - * and the read cannot be followed. ENOENT is reported apart from every other - * failure, because a path that names nothing is not a credential and the - * caller falls through to the next source, while a path that names the wrong - * file is a hard stop. + * ASC_KEY_PATH IS NOT A CREDENTIAL SOURCE (2026-10-21, Kody rule 42 on PR + * #271): not as a fallback, not as a lower priority, not at all. A named + * path is ignored wherever it appears, so a stale or foreign path in an + * operator's shell can never decide which credential signs a request. * * The command line never carries key material: - * read check that a staged key file holds a usable key * validate check that a key on stdin holds a usable key * write read a key on stdin and write it to a mode-600 file - * exec -- [args...] - * run a command with the staged key in its environment * * WHY NOTHING PRINTS THE KEY (2026-10-21, Kody rule 42 on PR #271): - * A key on stdout is a key in a log, in a CI transcript, in a process - * listing and in whatever captures that output next. So `read` and - * `validate` report the STATUS of a key and never the key itself, and the - * only way the key reaches a consumer is `exec`, which puts it in the - * child's environment as ASC_KEY_P8: never in an argument, never on - * stdout, never in a log line. + * A key on stdout is a key in a log, in a CI transcript and in whatever + * captures that output next. So `validate` reports the STATUS of a key and + * never the key itself, and the key reaches a consumer through the process + * environment: never in an argument, never on stdout, never in a log line. + * + * WHAT THE ENVIRONMENT IS NOT (2026-10-21, audit on PR #271): + * The key is not in argv, not on stdout and not in a log line, and a child + * is given an allowlist of variables instead of a copy of the parent's + * environment. The environment of a process is still readable by another + * process of the same user on this platform: `ps eww` and `ps -E` show it, + * and the PEM envelope is in the listing of the key-bearing child. So the + * handoff keeps the key out of the places a process listing is read for (the + * command line) and out of every log, and it does not claim more than that. */ -import { closeSync, constants, fstatSync, openSync, readFileSync, writeFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; import { createPrivateKey } from "node:crypto"; -// A private key is a few kilobytes. Anything larger is not one. -const MAX_KEY_BYTES = 64 * 1024; - // Normalizes a PEM private key and refuses anything that is not one. // // The envelope alone is not enough: a truncated key, an armored block or a @@ -60,50 +57,13 @@ export function normalizePrivateKey(value) { return pem.endsWith("\n") ? pem : `${pem}\n`; } -// Reads a staged key file. -// -// Returns { pem } when the file holds a usable private key, { missing: true } -// when the path names nothing at all, and { error } for every other problem. -// -// The open, the inspection and the read all use one descriptor, so nothing -// that happens to the path afterwards can change what is read. -export function readPrivateKeyFile(keyPath) { - let fd; - try { - fd = openSync(keyPath, constants.O_RDONLY | constants.O_NOFOLLOW); - } catch (err) { - if (err && err.code === "ENOENT") return { missing: true }; - if (err && err.code === "ELOOP") { - return { error: `the key path ${keyPath} is a symbolic link, which is not read` }; - } - return { error: `the key file ${keyPath} could not be read` }; - } - try { - const info = fstatSync(fd); - if (!info.isFile()) { - return { error: `the key path ${keyPath} is not a regular file` }; - } - if ((info.mode & 0o077) !== 0) { - const mode = (info.mode & 0o777).toString(8).padStart(3, "0"); - return { error: `the key file ${keyPath} is readable by others (mode ${mode}); a private key has to be owner-only (chmod 600)` }; - } - if (info.size > MAX_KEY_BYTES) { - return { error: `the key file ${keyPath} is ${info.size} bytes, which is not a private key` }; - } - const pem = normalizePrivateKey(readFileSync(fd, "utf8")); - if (!pem) { - return { error: `the key file ${keyPath} does not hold a PEM private key` }; - } - return { pem }; - } finally { - closeSync(fd); - } -} - // Writes a key to a file only its owner can read. // // The key arrives on stdin, so it never becomes a command argument, and the -// file is created with mode 600 whatever the umask of the caller says. +// file is created with mode 600 whatever the umask of the caller says. The +// file is a staging copy for the Apple tools that need a key on disk, and it +// is never read back as a credential: the credential comes from the +// environment or from fleet-secrets. export function writePrivateKeyFile(keyPath, value) { const pem = normalizePrivateKey(value); if (!pem) return { error: "the value is not a PEM private key" }; @@ -115,71 +75,59 @@ export function writePrivateKeyFile(keyPath, value) { return { pem }; } -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf8"); -} - -// Runs a command with the staged key in its environment. +// The variables the App Store Connect client needs from the environment. // -// The key goes into the child's environment as ASC_KEY_P8, which is where the -// client looks for it. It is never an argument, never reaches this process's -// stdout or stderr, and never reaches a log line. +// The child's environment is an allowlist, never a copy of the parent's: a +// wholesale copy hands every credential on the operator's machine to the +// child, including the ones that have nothing to do with App Store Connect. +// These are the variables the client reads (see resolveCredential in +// asc-api.mjs), plus PATH and HOME, which it needs to find fleet-secrets and +// to reach the bootstrap Keychain. +export const CLIENT_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "ASC_API_BASE", + "ASC_REQUEST_TIMEOUT_MS", + "ASC_KEY_ID", + "ASC_ISSUER_ID", + "APPLE_API_KEY_ID", + "APPLE_API_ISSUER_ID", + "APPLE_API_KEY_P8_BASE64", + "FLEET_SECRETS_BIN", + "FLEET_SECRETS" +]; + +// Builds the environment a child of the key handoff runs with. // -// The child's exit code is returned, so a caller that runs the client through -// this keeps the client's own result. -export function execWithKey(keyPath, command, args) { - const read = readPrivateKeyFile(keyPath); - if (read.missing) return { missing: true }; - if (read.error) return { error: read.error }; - const child = spawnSync(command, args, { - stdio: "inherit", - env: { ...process.env, ASC_KEY_P8: read.pem } - }); - if (child.error) { - return { error: `the command could not be started: ${child.error.message}` }; - } - if (child.signal) { - return { error: `the command was killed by ${child.signal}` }; +// Only the allowlisted variables cross over, and the key is added +// separately: it goes in as ASC_KEY_P8 and in no other variable. +export function clientEnvironment(env = process.env, extra = {}) { + const child = {}; + for (const name of CLIENT_ENV_ALLOWLIST) { + const value = env[name]; + if (typeof value === "string" && value.length > 0) child[name] = value; } - return { status: typeof child.status === "number" ? child.status : 1 }; + return { ...child, ...extra }; } -// The exit codes the shell layer reads: -// 0 the key was read -// 1 the path names something that is not a usable key -// 3 the path names nothing at all -export const EXIT_MISSING = 3; +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} async function main() { const [command, target] = process.argv.slice(2); - if (command === "read") { - if (!target) { - console.error("asc-key: usage: asc-key.mjs read "); - return 1; - } - const read = readPrivateKeyFile(target); - if (read.missing) { - console.error(`asc-key: the key file ${target} does not exist`); - return EXIT_MISSING; - } - if (read.error) { - console.error(`asc-key: ${read.error}`); - return 1; - } - // The status is reported and the key is not: a key on stdout is a key - // in every log that captures it. - console.log(`asc-key: ${target} holds a PEM private key`); - return 0; - } if (command === "validate") { const pem = normalizePrivateKey(await readStdin()); if (!pem) { console.error("asc-key: the value is not a PEM private key"); return 1; } - // The key is not printed here either: see the read command. + // The key is not printed here either: see the module header. console.log("asc-key: the value is a PEM private key"); return 0; } @@ -195,25 +143,7 @@ async function main() { } return 0; } - if (command === "exec") { - const rest = process.argv.slice(3); - const separator = rest.indexOf("--"); - if (!target || separator < 0 || separator === rest.length - 1) { - console.error("asc-key: usage: asc-key.mjs exec -- [args...]"); - return 1; - } - const child = execWithKey(target, rest[separator + 1], rest.slice(separator + 2)); - if (child.missing) { - console.error(`asc-key: the key file ${target} does not exist`); - return EXIT_MISSING; - } - if (child.error) { - console.error(`asc-key: ${child.error}`); - return 1; - } - return child.status; - } - console.error("asc-key: usage: asc-key.mjs read | validate | write | exec -- [args...]"); + console.error("asc-key: usage: asc-key.mjs validate | write "); return 1; } diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index acb171d9..36485624 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -84,10 +84,12 @@ # ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (env first, e.g. CI; else Infisical via # `fleet-secrets get` from Fleet-OPS /asc: ASC_KEY_ID, ASC_ISSUER_ID and the .p8 in ASC_KEY_P8, # written to a 0600 temp file and deleted on exit) -# A named ASC_KEY_PATH is never read by the node client, because a path on disk -# is not an approved secret source. The key material stays in that file: the -# client is handed the key in the child's environment (see asc_client), and -# altool and Xcode are given the path. +# A named ASC_KEY_PATH is not a credential source at all: it is ignored, so +# a stale or foreign path in an operator's shell can never decide which +# credential signs a request. The credential comes from the environment or +# from Infisical, and the key is handed to the node client in the client's +# environment (see asc_client). altool and Xcode are given the path of the +# staged copy this script writes for them. # or Xcode-signed-in session for destination=upload export # SENTRY_AUTH_TOKEN (dSYM + Size Analysis upload; env or `fleet-secrets get`) # SENTRY_DSN (injected into xcodebuild so Cocoa plist $(SENTRY_DSN) is set) @@ -218,28 +220,26 @@ fs_get() { # altool and Xcode, so it hands the key to the client in the environment, # which is where the client looks for it. # -# THE ENVIRONMENT IS RESOLVED FIRST (2026-10-21, Kody rule 42 on PR #271): +# THE CREDENTIAL IS RESOLVED FROM TWO PLACES (2026-10-21, Kody rule 42 on +# PR #271): # 1. ASC_KEY_P8, when it holds a PEM private key and comes with its key id -# 2. a named ASC_KEY_PATH, used as a FILE and never read into a variable -# 3. Infisical Fleet-OPS /asc (or /authkey--p8) -# A disk credential never overrides the environment: a stale or foreign value -# in an operator's shell would sign the upload with the wrong key, and App -# Store Connect would answer 401 without saying which input was wrong. The -# staged file stays a file: the key material never enters this shell, and the -# client is handed the key in the child's environment by the hardened reader -# (see asc_client). +# 2. Infisical Fleet-OPS /asc (or /authkey--p8) +# A disk credential is not a source at all: a named ASC_KEY_PATH is ignored, +# so a stale or foreign path in an operator's shell can never sign the +# upload with the wrong key, and App Store Connect would answer 401 without +# saying which input was wrong. The key material never enters a command +# argument and never reaches a log line. load_secrets() { AUTH_MODE="none" local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower - local key_pem="" key_path="" staged_path="" rc=0 - # The staged key file, when it is the credential in play. The client is run - # through the hardened reader, which puts the key in the child's - # environment; the key itself never enters this shell. - ASC_STAGED_KEY_PATH="" + local key_pem="" key_path="" rc=0 + # A named ASC_KEY_PATH is not a credential source. It is remembered only + # so the run can say that it ignored it. + local named_path="${ASC_KEY_PATH:-}" # 1. An ambient ASC_KEY_P8. It is only usable when it really is a PEM # private key and comes with the key id and issuer it belongs to. It is - # resolved first, so nothing on disk can override it. + # resolved first, so nothing can override it. if [[ -n "${ASC_KEY_P8:-}" ]]; then if [[ -z "$kid" || -z "$iss" ]]; then log "ASC_KEY_P8 is set without ASC_KEY_ID and ASC_ISSUER_ID; ignoring it" @@ -256,31 +256,10 @@ load_secrets() { fi fi - # 2. A key file the operator named. It is checked with the hardened - # reader, which reports whether the file holds a usable key and never - # prints the key, so the key material stays in the file. A path that - # names nothing falls through to the fleet credential instead of - # aborting the run. - if [[ -z "$key_pem" && -n "$kid" && -n "$iss" && -n "${ASC_KEY_PATH:-}" ]]; then - local expanded - expanded="$(expand_tilde "$ASC_KEY_PATH")" - rc=0 - node "${FLEET_DIR}/asc-key.mjs" read "$expanded" >/dev/null || rc=$? - if [[ "$rc" -eq 0 ]]; then - staged_path="$expanded" - else - if [[ "$rc" -eq 3 ]]; then - log "ASC_KEY_PATH names a file that does not exist; falling back to the fleet credential" - else - log "ASC_KEY_PATH does not hold a usable private key; falling back to the fleet credential" - fi - fi - fi - - # 3. The key id, issuer and private key must belong together: with nothing named in the + # 2. The key id, issuer and private key must belong together: with nothing named in the # environment all three come from /asc; an env key id that is not the /asc one gets its # own .p8 from /authkey--p8, never the /asc key. - if [[ -z "$key_pem" && -z "$staged_path" ]]; then + if [[ -z "$key_pem" ]]; then fkid="$(fs_get ASC_KEY_ID /asc)" || fkid="" if [[ -z "$kid" ]]; then kid="$fkid" @@ -308,31 +287,27 @@ load_secrets() { fi # The key has to be in a mode-600 file for altool and Xcode, and in the - # environment for the node client, which never reads a file. A staged key - # file is already a mode-600 file, so it is used where it lies. - if [[ -n "$staged_path" ]]; then - key_path="$staged_path" - ASC_STAGED_KEY_PATH="$staged_path" - elif [[ -z "$key_path" ]]; then - if [[ -z "$ASC_TMP_DIR" ]]; then - ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" - fi - key_path="${ASC_TMP_DIR}/AuthKey_${kid}.p8" - if ! node "${FLEET_DIR}/asc-key.mjs" write "$key_path" <<< "$key_pem"; then - log "could not write the ASC key file; the credential is not usable" - return 0 - fi - # altool searches API_PRIVATE_KEYS_DIR first, so no symlink into ~/.appstoreconnect is needed. - export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" + # environment for the node client, which never reads a file. The file is + # written from the credential that was just resolved, so it is never read + # back as a credential. + if [[ -z "$ASC_TMP_DIR" ]]; then + ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" + fi + key_path="${ASC_TMP_DIR}/AuthKey_${kid}.p8" + if ! node "${FLEET_DIR}/asc-key.mjs" write "$key_path" <<< "$key_pem"; then + log "could not write the ASC key file; the credential is not usable" + return 0 fi + # altool searches API_PRIVATE_KEYS_DIR first, so no symlink into ~/.appstoreconnect is needed. + export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="$key_path" # The client takes the key from the environment: a path is not an approved - # secret source, so it never reads one. A staged key file is handed over - # by the hardened reader instead, in the child's environment. - if [[ -n "$key_pem" ]]; then - export ASC_KEY_P8="$key_pem" - fi + # secret source, so it never reads one. + export ASC_KEY_P8="$key_pem" key_pem="" + if [[ -n "$named_path" && "$named_path" != "$key_path" ]]; then + log "ASC_KEY_PATH is not a credential source; the credential came from the environment or from Infisical" + fi AUTH_MODE="api_key" return 0 } @@ -340,16 +315,10 @@ load_secrets() { # Run the App Store Connect client. # # The client takes the key from its environment and never reads a file (Kody -# rule 42). When the credential in play is a staged key file, the hardened -# reader hands the key to the client in the child's environment: the key is -# never an argument, never on this script's stdout, and never in a log line. +# rule 42). The key is never an argument, never on this script's stdout, and +# never in a log line. asc_client() { - if [[ -n "${ASC_STAGED_KEY_PATH:-}" ]]; then - node "${FLEET_DIR}/asc-key.mjs" exec "$ASC_STAGED_KEY_PATH" -- \ - node "${FLEET_DIR}/asc-api.mjs" "$@" - else - node "${FLEET_DIR}/asc-api.mjs" "$@" - fi + node "${FLEET_DIR}/asc-api.mjs" "$@" } # Map ship APP_KEY to Sentry project slug in org simple-with-us. diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index bf2ae994..e54d151a 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -61,12 +61,46 @@ extract() { ' "$script" } +# The comparison tool. It answers through the exit code only, so a +# credential is never expanded into the shell, where a trace would show it. +# +# same the value in VALUE_TO_COMPARE equals the file +# different the value in VALUE_TO_COMPARE differs from the file +# samefile the two files hold the same key +# differentfile the two files hold different keys +cat > "${tmp}/compare.cjs" <<'CJS' +const fs = require("fs"); +const mode = process.argv[2]; +const first = process.argv[3]; +const second = process.argv[4]; +const value = process.env.VALUE_TO_COMPARE || ""; +const read = (path) => fs.readFileSync(path, "utf8").trim(); +const equal = second ? read(first) === read(second) : value.trim() === read(first); +const wantsDifferent = mode === "different" || mode === "differentfile"; +process.exit(wantsDifferent ? (equal ? 1 : 0) : (equal ? 0 : 1)); +CJS + funcs="${tmp}/funcs.sh" { echo 'log() { :; }' echo 'ASC_TMP_DIR=""' echo "FLEET_SECRETS_BIN=\"${tmp}/fleet-secrets\"" echo "FLEET_DIR=\"${here}\"" + echo "ASC_COMPARE_TOOL=\"${tmp}/compare.cjs\"" + cat <<'FUNCS' +same_as_file() { + VALUE_TO_COMPARE="$1" node "$ASC_COMPARE_TOOL" same "$2" +} +different_from_file() { + VALUE_TO_COMPARE="$1" node "$ASC_COMPARE_TOOL" different "$2" +} +same_file_as_file() { + node "$ASC_COMPARE_TOOL" samefile "$1" "$2" +} +different_file_from_file() { + node "$ASC_COMPARE_TOOL" differentfile "$1" "$2" +} +FUNCS extract } > "$funcs" grep -q '^load_secrets()' "$funcs" || fail "load_secrets not found in ship-testflight.sh" @@ -83,10 +117,10 @@ out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH -u ASC_KEY_P8 bash -c mode="$(stat -c %a "$ASC_KEY_PATH" 2>/dev/null || stat -f %Lp "$ASC_KEY_PATH")" [[ "$mode" == 600 ]] [[ "$API_PRIVATE_KEYS_DIR" == "$ASC_TMP_DIR" ]] - # The staged file holds the /asc key. - [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/asc.p8")" ]] - # The client takes the key from the environment, never from a path. - [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] + # The staged file holds the /asc key, and the client takes the key from the + # environment. Neither comparison prints a value. + same_file_as_file "$ASC_KEY_PATH" "'"$tmp"'/asc.p8" + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" echo "$ASC_KEY_PATH" cleanup_asc_tmp [[ ! -e "$ASC_KEY_PATH" ]] @@ -107,40 +141,117 @@ ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer \ source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 ]] - # The environment key is the credential in play. - [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/other.p8")" ]] - [[ "$(printf "%s" "$ASC_KEY_P8")" != "$(cat "'"$tmp"'/asc.p8")" ]] - # The named file was not read: no file was staged for the client. - [[ -z "${ASC_STAGED_KEY_PATH:-}" ]] + # The environment key is the credential in play, and the named file is not. + same_as_file "$ASC_KEY_P8" "'"$tmp"'/other.p8" + different_from_file "$ASC_KEY_P8" "'"$tmp"'/named-key.p8" ' || fail "a named key file overrode the environment" [[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" -# 2a. A named key file is used as a FILE: the key material never enters this -# shell, the file is used where it lies, and the client is handed the key in -# the child's environment by the hardened reader. +# 2a. A named ASC_KEY_PATH is not a credential source at all: it is ignored, +# and the credential comes from Infisical. A file somebody names can never +# decide which key signs the upload (Kody rule 42 on PR #271). : > "$FAKE_LOG" -cp "${tmp}/asc.p8" "${tmp}/env-key.p8" -chmod 600 "${tmp}/env-key.p8" -env -u ASC_KEY_P8 ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer ASC_KEY_PATH="${tmp}/env-key.p8" bash -c ' +cp "${tmp}/other.p8" "${tmp}/named-file.p8" +chmod 600 "${tmp}/named-file.p8" +env -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 ASC_ISSUER_ID=issuer-synthetic ASC_KEY_PATH="${tmp}/named-file.p8" bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets - [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 && -z "$ASC_TMP_DIR" ]] - # The named file is used where it lies: no copy is made of it. - [[ "$ASC_KEY_PATH" == "'"${tmp}"'/env-key.p8" ]] - # The key stays in the file: it never enters this shell. - [[ -z "${ASC_KEY_P8:-}" ]] - # The client is handed the key from the file, in the child environment. - [[ "$ASC_STAGED_KEY_PATH" == "'"${tmp}"'/env-key.p8" ]] -' || fail "the named key file was not used as a file" -[[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the named file was usable" + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] + # The credential is the /asc one from Infisical, never the named file. + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" + different_from_file "$ASC_KEY_P8" "'"$tmp"'/named-file.p8" + cleanup_asc_tmp +' || fail "a named key file was used as the credential" +grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "fleet-secrets was not consulted for the credential" + +# The scanner that finds a command substitution which runs the key reader. +# +# It walks the file and matches the closing parenthesis of every +# substitution, so a capture is found whatever shape it has: with or without +# a `||` after it, written on one line or across several, and with a nested +# substitution inside it. The scan is exact, so the script as it stands is +# not flagged by a shape it does not have. +cat > "${tmp}/find-key-capture.cjs" <<'SCAN' +const fs = require("fs"); +const text = fs.readFileSync(process.argv[2], "utf8"); +const offenders = []; +const lineOf = (index) => text.slice(0, index).split("\n").length; +let depth = 0; +let start = 0; +let quote = null; +for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + if (quote === "'") { + // Single quotes are literal in bash, so nothing inside them is a + // substitution. + if (c === quote) quote = null; + continue; + } + if (c === "\\") { i += 1; continue; } + if (c === "`") { + const end = text.indexOf("`", i + 1); + if (end === -1) break; + if (text.slice(i, end + 1).includes("asc-key.mjs")) offenders.push(lineOf(i)); + i = end; + continue; + } + if (quote === "\"") { + if (c === quote) { quote = null; continue; } + if (c === "$" && text[i + 1] === "{") { const end = text.indexOf("}", i); if (end !== -1) { i = end; continue; } } + if (c === "$" && text[i + 1] === "(") { depth += 1; if (depth === 1) start = i; i += 1; continue; } + if (c === ")" && depth > 0) { + depth -= 1; + if (depth === 0 && text.slice(start, i + 1).includes("asc-key.mjs")) offenders.push(lineOf(start)); + } + continue; + } + if (c === "$" && text[i + 1] === "{") { const end = text.indexOf("}", i); if (end !== -1) { i = end; continue; } } + if (c === "#") { + // A comment runs to the end of the line, and an apostrophe in it is an + // apostrophe, not the start of a quoted string. + const end = text.indexOf("\n", i); + i = end === -1 ? text.length : end; + continue; + } + if (c === "\"" || c === "'") { quote = c; continue; } + if (c === "$" && text[i + 1] === "(") { depth += 1; if (depth === 1) start = i; i += 1; continue; } + if (c === ")" && depth > 0) { + depth -= 1; + if (depth === 0 && text.slice(start, i + 1).includes("asc-key.mjs")) offenders.push(lineOf(start)); + } +} +if (offenders.length) { + console.error("the key reader is captured into a variable at line(s) " + offenders.join(", ")); + process.exit(1); +} +SCAN # 2b. A staged key file is never read into a variable: the shell that owns it # only ever holds its path. -if grep -nE 'asc-key\.mjs" read .*\)" \|\|' "$script" >/dev/null; then +if ! node "${tmp}/find-key-capture.cjs" "$script"; then fail "ship-testflight.sh reads the key material into a variable" fi +# 2b. The guard has teeth: a capture in every shape is caught, and the ship +# script as it stands is not flagged. +for shape in plain plain-with-or split-across-lines nested; do + cp "$script" "${tmp}/mutated.sh" + case "$shape" in + plain) + printf '%s\n' 'key="$(node "${FLEET_DIR}/asc-key.mjs" read "$ASC_KEY_PATH")"' >> "${tmp}/mutated.sh" ;; + plain-with-or) + printf '%s\n' 'key="$(node "${FLEET_DIR}/asc-key.mjs" read "$ASC_KEY_PATH")" || rc=$?' >> "${tmp}/mutated.sh" ;; + split-across-lines) + printf '%s\n%s\n' 'key="$(node "${FLEET_DIR}/asc-key.mjs" \' ' read "$ASC_KEY_PATH")"' >> "${tmp}/mutated.sh" ;; + nested) + printf '%s\n' 'key="$(cd "$(dirname "$0")"; node "${FLEET_DIR}/asc-key.mjs" read "$ASC_KEY_PATH")"' >> "${tmp}/mutated.sh" ;; + esac + if node "${tmp}/find-key-capture.cjs" "${tmp}/mutated.sh" >/dev/null 2>&1; then + fail "the capture guard did not catch a ${shape} capture" + fi +done + # 2b. An ambient ASC_KEY_P8 that is not a PEM private key is ignored, and the # run fails closed rather than signing with something that is not a key. : > "$FAKE_LOG" @@ -160,7 +271,7 @@ ASC_KEY_ID="" ASC_ISSUER_ID="" ASC_KEY_P8="$(<"${tmp}/other.p8")" bash -c ' source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] - [[ "$(printf "%s" "$ASC_KEY_P8")" == "$(cat "'"$tmp"'/asc.p8")" ]] + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" ' || fail "an ASC_KEY_P8 without a key id was adopted" # 2d. An env key id that is NOT the /asc one never gets the /asc key: its own .p8 comes from @@ -172,8 +283,8 @@ env -u ASC_KEY_PATH -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-oth load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == OTHERKEY01 ]] [[ "$ASC_ISSUER_ID" == issuer-other ]] - [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/other.p8")" ]] - [[ "$(cat "$ASC_KEY_PATH")" != "$(cat "'"$tmp"'/asc.p8")" ]] + same_file_as_file "$ASC_KEY_PATH" "'"$tmp"'/other.p8" + different_file_from_file "$ASC_KEY_PATH" "'"$tmp"'/asc.p8" cleanup_asc_tmp ' || fail "env key id different from /asc was not given its own key" grep -q '^AUTHKEY_OTHERKEY01 /authkey-otherkey01-p8$' "$FAKE_LOG" || fail "did not ask the per-key folder" @@ -197,7 +308,7 @@ env -u ASC_KEY_PATH -u ASC_ISSUER_ID -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 bash -c source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] - [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/asc.p8")" ]] + same_file_as_file "$ASC_KEY_PATH" "'"$tmp"'/asc.p8" cleanup_asc_tmp ' || fail "env key id equal to /asc was not served from /asc" grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "did not ask /asc ASC_KEY_P8" @@ -211,20 +322,18 @@ env -u ASC_KEY_PATH -u ASC_KEY_P8 ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer [[ "$AUTH_MODE" == none && -z "$ASC_TMP_DIR" ]] ' || fail "missing key material did not fail closed" -# 2h. A named ASC_KEY_PATH that does not exist is not a credential: the run -# falls through to the fleet credential instead of aborting, and the path is -# reported as missing rather than as unreadable. +# 2h. A named ASC_KEY_PATH that names nothing is ignored the same way: the +# credential comes from Infisical, and the named path is never read. : > "$FAKE_LOG" -err="$(env -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-other ASC_KEY_PATH="${tmp}/no-such-key.p8" bash -c ' +env -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 ASC_ISSUER_ID=issuer-synthetic ASC_KEY_PATH="${tmp}/no-such-key.p8" bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets - [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == OTHERKEY01 ]] - [[ "$ASC_KEY_PATH" != "'"${tmp}"'/no-such-key.p8" ]] - [[ "$(cat "$ASC_KEY_PATH")" == "$(cat "'"$tmp"'/other.p8")" ]] + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" cleanup_asc_tmp -' 2>&1 >/dev/null)" || fail "a missing key file did not fall through to the fleet credential" -[[ "$err" == *"does not exist"* ]] || fail "a missing key file was not reported as missing: ${err}" +' || fail "a named key file that names nothing broke the run" +grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "fleet-secrets was not consulted for the credential" # 2i. Only a leading "~/" is expanded: "~user/x" must not be merged with $HOME. env -u ASC_KEY_P8 ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' @@ -261,4 +370,4 @@ if grep -nE '(~|\$\{?HOME\}?)/\.secrets' "$script" >/dev/null; then fail "ship-testflight.sh still names the old secrets folder" fi -echo "ok: ASC credential resolution (env first, then the named file as a file, then Infisical, temp key removed)" +echo "ok: ASC credential resolution (environment first, then Infisical, a named path ignored, temp key removed)" diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs index 89921223..a68d7d7e 100644 --- a/scripts/ios-fleet/test-asc-key.test.mjs +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -1,18 +1,17 @@ -// The staged App Store Connect key file: how it is validated, and how it is -// read. +// The App Store Connect private key: how it is validated, how it is staged, +// and how it reaches the client. // -// The private key is a secret, and the approved secret sources are the -// process environment, a captured `fleet-secrets get NAME` and bootstrap-only -// Keychain credentials. A caller-supplied file is not one of them, so the -// node client never reads a key file: the shell layer that owns the -// credential reads the staged key and hands it over in the environment. These -// tests cover that reader, because it is the one place a file is read. +// The private key is a secret, and the approved secret sources are the process +// environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain +// credentials. A caller-supplied file is not one of them, so nothing reads a +// key file: the key travels in the environment, and the file this module +// writes is a staging copy for the Apple tools that need a key on disk. // // Everything here is synthetic: the keys are generated at test time and are // never committed, and nothing reaches the network. import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { generateKeyPairSync } from "node:crypto"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; @@ -20,7 +19,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; -import { normalizePrivateKey, readPrivateKeyFile } from "./asc-key.mjs"; +import { CLIENT_ENV_ALLOWLIST, clientEnvironment, normalizePrivateKey } from "./asc-key.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const helper = join(here, "asc-key.mjs"); @@ -32,6 +31,10 @@ const root = mkdtempSync(join(tmpdir(), "asc-key-test-")); // A PATH with node and nothing else: the fleet reader lives in the operator's // own bin directory, and a test must never reach it or Infisical. const safePath = [dirname(process.execPath), "/usr/bin", "/bin"].join(":"); +// A secret that has nothing to do with App Store Connect. It is in the +// environment of the parent on purpose, so the tests can show that it does +// not reach the client. +const unrelatedSecret = "unrelated-runtime-secret-4f1c9a"; const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey .export({ type: "pkcs8", format: "pem" }); @@ -43,9 +46,12 @@ function staged(name, content = keyPem, mode = 0o600) { return path; } -function run(args, stdin = "") { +function run(args, stdin = "", env = undefined) { return new Promise((resolve) => { - const child = spawn(process.execPath, [helper, ...args], { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(process.execPath, [helper, ...args], { + ...(env ? { env } : {}), + stdio: ["pipe", "pipe", "pipe"] + }); let stdout = ""; let stderr = ""; child.stdout.on("data", (d) => { stdout += d; }); @@ -74,69 +80,6 @@ test("a malformed PEM is refused, because the envelope is not a key", () => { assert.equal(normalizePrivateKey("hunter2"), null); }); -test("a staged key file is read and its key is returned", () => { - const path = staged("good.p8"); - const read = readPrivateKeyFile(path); - assert.ok(read.pem, `the staged key was not read: ${read.error}`); - assert.equal(read.pem.trim(), keyPem.trim()); -}); - -test("a path that names nothing is reported apart from every other failure", () => { - // A path that names nothing is not a credential, so the caller falls - // through to the next source instead of aborting the run. - const read = readPrivateKeyFile(join(root, "no-such-key.p8")); - assert.equal(read.missing, true); - assert.equal(read.error, undefined); -}); - -test("a symlink is refused rather than followed", () => { - // Every ordinary file read follows a symlink, so a path that looks like a - // key file could resolve to something else entirely. The path is opened - // once with O_NOFOLLOW, so the symlink is never followed. - const target = staged("target.p8"); - const link = join(root, "linked.p8"); - symlinkSync(target, link); - const read = readPrivateKeyFile(link); - assert.ok(read.error, "a symlinked key path was read"); - assert.match(read.error, /symbolic link/); -}); - -test("a key file other users can read is refused", () => { - // A private key another user can read is already leaked. - const path = staged("loose.p8", keyPem, 0o644); - const read = readPrivateKeyFile(path); - assert.ok(read.error, "a key file readable by others was accepted"); - assert.match(read.error, /readable by others/); -}); - -test("a file that is not a private key is refused", () => { - const path = staged("not-a-key.p8", "hunter2 this is not a private key\n"); - const read = readPrivateKeyFile(path); - assert.ok(read.error, "a file that is not a key was accepted"); - assert.match(read.error, /does not hold a PEM private key/); -}); - -test("the command line reports the status of a staged key and never prints it", async () => { - // A key on stdout is a key in every log that captures it, so the reader - // reports whether the file is usable and nothing else. - const path = staged("cli.p8"); - const ok = await run(["read", path]); - assert.equal(ok.code, 0, `the staged key was refused: ${ok.stderr}`); - assert.match(ok.stdout, /holds a PEM private key/, `the status was not reported:\n${ok.stdout}`); - assert.doesNotMatch(ok.stdout, /PRIVATE KEY/, "the key reached stdout"); - assert.doesNotMatch(ok.stderr, /PRIVATE KEY/, "the key reached stderr"); - - const missing = await run(["read", join(root, "no-such-key.p8")]); - assert.equal(missing.code, 3, "a path that names nothing must report EXIT_MISSING"); - assert.match(missing.stderr, /does not exist/); - assert.equal(missing.stdout, "", "a missing key must not put anything on stdout"); - - const malformed = await run(["read", staged("bad.p8", "this is not a private key\n")]); - assert.equal(malformed.code, 1, "a file that is not a key must be refused"); - assert.match(malformed.stderr, /does not hold a PEM private key/); - assert.equal(malformed.stdout, "", "a refused key must not put anything on stdout"); -}); - test("the key never travels in the command arguments", async () => { // The key goes in on stdin, and the command reports the status of it // without printing it, so it is never an argument of any process and never @@ -148,26 +91,24 @@ test("the key never travels in the command arguments", async () => { assert.ok(!result.stderr.includes("PRIVATE KEY"), "the key envelope reached a log line"); }); -test("the exec command hands the key to the child in its environment", async () => { - // This is the handoff the hosted runner uses: the staged key reaches the - // consumer in the child's environment, and in nowhere else. Not in an - // argument, not on stdout, not on stderr, not in a log line. - const path = staged("exec.p8"); - const child = await run(["exec", path, "--", process.execPath, "-e", - "process.stdout.write('seen:' + (process.env.ASC_KEY_P8 ? 'yes' : 'no') + ':' + String(process.env.ASC_KEY_P8 || '').length)"]); - assert.equal(child.code, 0, `the child did not run: ${child.stderr}`); - assert.match(child.stdout, /^seen:yes:\d+$/, `the child did not receive the key:\n${child.stdout}`); - assert.doesNotMatch(child.stdout + child.stderr, /PRIVATE KEY/, "the key reached the output"); +test("the command line refuses a value that is not a key", async () => { + const result = await run(["validate"], "not a private key"); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /not a PEM private key/); }); -test("the exec command reports a missing key and passes the child's exit code on", async () => { - const missing = await run(["exec", join(root, "no-such-key.p8"), "--", process.execPath, "-e", "process.exit(0)"]); - assert.equal(missing.code, 3, "a path that names nothing must report EXIT_MISSING"); - assert.match(missing.stderr, /does not exist/); - - const path = staged("exit.p8"); - const child = await run(["exec", path, "--", process.execPath, "-e", "process.exit(7)"]); - assert.equal(child.code, 7, "the child's exit code was not passed on"); +test("the command line has no way to read a key file", async () => { + // A path is not an approved secret source, so the command line has no + // command that reads one: the key comes from the environment or from + // fleet-secrets, and nowhere else. + const path = staged("no-reader.p8"); + const read = await run(["read", path]); + assert.notEqual(read.code, 0, "the command line read a key file"); + assert.match(read.stderr, /usage/); + assert.equal(read.stdout, "", "a refused command must not put anything on stdout"); + const exec = await run(["exec", path, "--", process.execPath, "-e", "process.exit(0)"]); + assert.notEqual(exec.code, 0, "the command line handed a key file to a child"); + assert.match(exec.stderr, /usage/); }); test("the command line writes a key to a file only its owner can read", async () => { @@ -178,74 +119,106 @@ test("the command line writes a key to a file only its owner can read", async () assert.equal(readFileSync(path, "utf8").trim(), keyPem.trim()); }); -test("the command line refuses a value that is not a key", async () => { +test("the command line refuses to write a value that is not a key", async () => { const path = join(root, "refused.p8"); const result = await run(["write", path], "not a private key"); assert.notEqual(result.code, 0); assert.match(result.stderr, /not a PEM private key/); }); -test("the staged key reaches the client through the environment", async () => { - // This is the hosted CI path end to end: the runner stages the key to a - // mode-600 file and publishes only its path, the hardened reader hands - // that key to the client in the child's environment, and the client takes - // the key from the environment. The client reads no file at all, which is - // why the path is in the environment here and the file is still the - // credential in play. - const path = staged("ci.p8"); - const client = spawn(process.execPath, [ - helper, "exec", path, "--", process.execPath, join(here, "asc-api.mjs"), "GET", "http://127.0.0.1:1/v1/apps" - ], { - env: { - PATH: process.env.PATH, - HOME: process.env.HOME, - ASC_API_BASE: "http://127.0.0.1:1", - ASC_KEY_ID: "SYNTHETICKEYID1", - ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: path - }, - stdio: ["ignore", "pipe", "pipe"] - }); - let stderr = ""; - client.stderr.on("data", (d) => { stderr += d; }); - client.stdout.resume(); - await new Promise((resolve) => client.on("close", resolve)); - assert.match(stderr, /asc-auth: using ASC_KEY_P8 environment/, `the client did not take the key from the environment:\n${stderr}`); - assert.doesNotMatch(stderr, /using ASC_KEY_PATH environment/); - assert.doesNotMatch(stderr, /PRIVATE KEY/, "the key reached the output"); +test("the child's environment is an allowlist, not a copy of the parent's", () => { + // A wholesale copy of the parent's environment hands every credential on + // the operator's machine to the child, including the ones that have + // nothing to do with App Store Connect. + const child = clientEnvironment({ + PATH: "/usr/bin", + HOME: "/tmp/home", + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + UNRELATED_RUNTIME_SECRET: unrelatedSecret + }, { ASC_KEY_P8: keyPem }); + assert.equal(child.UNRELATED_RUNTIME_SECRET, undefined, "an unrelated secret reached the child"); + assert.equal(child.ASC_KEY_P8.trim(), keyPem.trim(), "the key was not handed over"); + assert.equal(child.ASC_KEY_ID, "SYNTHETICKEYID1"); + assert.deepEqual(Object.keys(child).sort(), ["ASC_ISSUER_ID", "ASC_KEY_ID", "ASC_KEY_P8", "HOME", "PATH"]); + // Every variable the child gets is on the allowlist. + for (const name of Object.keys(child)) { + assert.ok(CLIENT_ENV_ALLOWLIST.includes(name) || name === "ASC_KEY_P8", + `${name} crossed over and is not on the allowlist`); + } +}); + +test("the credential comes from the environment, and a path is not a source", async () => { + const { credentialEnvironment } = await import(pathToFileURL(apnsScript).href); + // The environment is an approved source. + const env = credentialEnvironment({ ASC_KEY_P8: keyPem }); + assert.equal(env.ASC_KEY_P8.trim(), keyPem.trim(), "the key was not handed over"); + assert.equal(Object.keys(env).length, 1, "the child environment carries nothing but the key"); + // A path on disk is not an approved source, so it is not read. + assert.deepEqual(credentialEnvironment({ ASC_KEY_PATH: staged("path-only.p8") }), {}); + // Nothing in the environment means nothing to hand over: the client + // resolves the credential from fleet-secrets on its own. + assert.deepEqual(credentialEnvironment({}), {}); + // A key that is set but is not a key is refused with the variable named, + // instead of being dropped and leaving the child with no credential and + // the operator with a permission message that points at nothing. + assert.throws( + () => credentialEnvironment({ ASC_KEY_P8: "not a private key" }), + /ASC_KEY_P8 is set but does not hold a PEM private key/ + ); }); // The consumers that call the client as a child process. // -// The client has no path source (Kody rule 42), so a consumer that runs in the -// hosted runner, where the staged key is published as a path, has to read that -// key and hand it to the child in the child's environment. These tests drive -// the two consumers that do that, and prove the key stays out of argv, out of -// the environment file, and out of every log line. +// The client has no path source (Kody rule 42), so the consumers hand it the +// key in the child's environment and give the child an allowlist of +// variables. These tests drive the two consumers that do that, and prove the +// key stays out of argv, out of the environment file and out of every log +// line. -function localServer({ holdFirst = 0 } = {}) { +// A server that answers the endpoints the consumers walk. +// +// Every request is held open until the test releases it, so the consumer is +// still alive, with its credential in its environment, while the process +// table is read. The `firstRequest` promise is the readiness signal: it +// settles when a request has arrived, which is after the consumer has built +// its environment and sent it. +function localServer({ hold = false } = {}) { const seen = []; - let requests = 0; + const pending = []; + let holding = hold; + let signal = null; + const firstRequest = new Promise((resolve) => { signal = resolve; }); + const respond = (req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + if (req.url.startsWith("/v1/bundleIds?")) { + res.end(JSON.stringify({ data: [{ type: "bundleIds", id: "BUNDLE1", + attributes: { identifier: "com.simplewithus.codecaps.ios" } }] })); + return; + } + res.end(JSON.stringify({ data: [] })); + }; const server = createServer((req, res) => { seen.push({ url: req.url, authorization: req.headers.authorization || "" }); - requests += 1; - // The first request is held open, so the child process is still alive - // while the process table is read. - const delay = requests === 1 ? holdFirst : 0; - setTimeout(() => { - res.writeHead(200, { "Content-Type": "application/json" }); - if (req.url.startsWith("/v1/bundleIds?")) { - res.end(JSON.stringify({ data: [{ type: "bundleIds", id: "BUNDLE1", - attributes: { identifier: "com.simplewithus.codecaps.ios" } }] })); - return; - } - res.end(JSON.stringify({ data: [] })); - }, delay); + signal(); + if (holding) { + pending.push(() => respond(req, res)); + return; + } + respond(req, res); }); return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => resolve({ base: `http://127.0.0.1:${server.address().port}`, seen, + firstRequest, + // Releasing answers every request that is held and stops holding the + // ones that come after it, so a consumer that asks for more than once + // can finish. + releaseAll: () => { + holding = false; + while (pending.length > 0) pending.shift()(); + }, close: () => server.close() })); }); @@ -273,29 +246,88 @@ function spawnBash(args, env) { }); } +// Spawns a consumer and hands back its pid, so the process table can be read +// while it is waiting on App Store Connect. +function spawnTracked(command, args, env) { + const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + const done = new Promise((resolve) => { + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); + return { + pid: child.pid, + done, + kill: () => { try { child.kill("SIGKILL"); } catch { /* already gone */ } } + }; +} + +// The command line of one process. +function processArgs(pid) { + return execFileSync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8" }); +} + +// The environment of one process, the way another process of the same user +// can read it on this platform. +function processEnvironment(pid) { + return execFileSync("ps", ["-E", "-p", String(pid), "-o", "args="], { encoding: "utf8" }); +} + +// The children of one process. +function childPids(pid) { + try { + const listed = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }); + return listed.split("\n").map((line) => line.trim()).filter(Boolean).map(Number); + } catch { + return []; + } +} + +// The client processes below one consumer. +function clientPidsBelow(pid) { + const found = []; + const walk = (parent) => { + for (const child of childPids(parent)) { + found.push(child); + walk(child); + } + }; + walk(pid); + return found.filter((child) => { + try { + return processArgs(child).includes("asc-api.mjs"); + } catch { + return false; + } + }); +} + +// Waits for the client of every consumer to be running. +// +// The wait is for the process itself and not for a fixed number of +// milliseconds: a fixed sleep is a race, and the process table is only +// worth reading while the process is there. +async function waitForClients(pids, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = pids.map((pid) => clientPidsBelow(pid)).filter((list) => list.length > 0); + if (found.length === pids.length) return found.flat(); + if (Date.now() > deadline) { + throw new Error(`only ${found.length} of ${pids.length} clients started`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + function jwtKeyId(authorization) { const token = String(authorization || "").replace(/^Bearer /, ""); if (!token) return null; return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString()).kid; } -test("the staged key is handed to the child in its environment", async () => { - const { stagedKeyEnvironment } = await import(pathToFileURL(apnsScript).href); - const path = staged("handoff.p8"); - const env = stagedKeyEnvironment({ ASC_KEY_PATH: path }); - assert.equal(env.ASC_KEY_P8.trim(), keyPem.trim(), "the staged key was not handed over"); - assert.equal(Object.keys(env).length, 1, "the child environment carries nothing but the key"); - // No staged key means no override, so a developer who exports ASC_KEY_P8 in - // their own shell keeps working. - assert.deepEqual(stagedKeyEnvironment({}), {}); - assert.deepEqual(stagedKeyEnvironment({ ASC_KEY_P8: "ambient" }), {}); - // A path that names nothing is not a credential, so the run falls through - // and the client resolves the credential the way it does today. - assert.deepEqual(stagedKeyEnvironment({ ASC_KEY_PATH: join(root, "no-such-key.p8") }), {}); -}); - test("the child client reports the ASC_KEY_P8 environment source", async () => { - const { stagedKeyEnvironment } = await import(pathToFileURL(apnsScript).href); const server = await localServer(); try { const result = await spawnNode([client, "GET", `${server.base}/v1/apps`], { @@ -304,18 +336,18 @@ test("the child client reports the ASC_KEY_P8 environment source", async () => { ASC_API_BASE: server.base, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ...stagedKeyEnvironment({ ASC_KEY_PATH: staged("client.p8") }) + ASC_KEY_P8: keyPem }); assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/, `the client did not report the environment source:\n${result.stderr}`); assert.equal(jwtKeyId(server.seen[0].authorization), "SYNTHETICKEYID1", - "the request was not signed with the staged key"); + "the request was not signed with the key from the environment"); } finally { server.close(); } }); -test("with no staged key and no ambient key the client reports that it has no credential", async () => { +test("with no key in the environment and no fleet secret the client reports that it has no credential", async () => { const result = await spawnNode([client, "GET", "http://127.0.0.1:1/v1/apps"], { // A bare PATH and a throwaway HOME keep the fleet reader out of the way, // so the client really has no source left. @@ -329,7 +361,7 @@ test("with no staged key and no ambient key the client reports that it has no cr assert.match(result.stderr, /no App Store Connect credential found/); }); -test("the APNs consumer signs its requests with the staged key", async () => { +test("the APNs consumer signs its requests with the key from the environment", async () => { const server = await localServer(); try { const home = join(root, "home-apns"); @@ -340,12 +372,11 @@ test("the APNs consumer signs its requests with the staged key", async () => { ASC_API_BASE: server.base, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: staged("apns.p8"), - ASC_KEY_P8: "" + ASC_KEY_P8: keyPem }); assert.ok(server.seen.length > 0, "the consumer never reached App Store Connect"); assert.equal(jwtKeyId(server.seen[0].authorization), "SYNTHETICKEYID1", - "the request was not signed with the staged key"); + "the request was not signed with the key from the environment"); assert.doesNotMatch(result.stdout + result.stderr, /BEGIN PRIVATE KEY/, "the key reached the consumer's output"); } finally { @@ -353,38 +384,46 @@ test("the APNs consumer signs its requests with the staged key", async () => { } }); -test("the key never reaches argv and never reaches the environment file", async () => { +test("the key never reaches argv, and the child's environment carries it", async () => { const needle = keyPem.split("\n").find((line) => line.length > 32); const envFile = join(root, "github-env"); writeFileSync(envFile, ""); const home = join(root, "home-argv"); mkdirSync(home, { recursive: true }); - const server = await localServer({ holdFirst: 1500 }); + const server = await localServer({ hold: true }); const env = { PATH: safePath, HOME: home, ASC_API_BASE: server.base, ASC_KEY_ID: "SYNTHETICKEYID1", ASC_ISSUER_ID: "synthetic-issuer", - ASC_KEY_PATH: staged("argv.p8"), - ASC_KEY_P8: "", + ASC_KEY_P8: keyPem, GITHUB_ENV: envFile }; - const runs = [ - spawnNode([apnsScript], env), - spawnBash([profilesScript], env) - ]; + const apns = spawnTracked(process.execPath, [apnsScript], env); + const profiles = spawnTracked("bash", [profilesScript], env); try { + // The request arriving is the readiness signal: the consumer has built + // its environment and sent it, so it is alive with the credential in it. + await server.firstRequest; + const clientPids = await waitForClients([apns.pid, profiles.pid]); // While the consumers are waiting on App Store Connect, no process may // carry the key in its command line. - await new Promise((resolve) => setTimeout(resolve, 700)); const table = execFileSync("ps", ["-A", "-o", "args="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); assert.ok(!table.includes(needle), "the key reached a command line"); - const results = await Promise.all(runs); - // Both consumers signed their requests with the staged key, so the - // handoff is what put the credential in front of the client. + // The environment of a same-user process is readable on this platform, + // and the key is in the environment of the key-bearing child by design. + // The check can see it there, so it fails when the handoff breaks. + for (const pid of clientPids) { + assert.ok(processEnvironment(pid).includes(needle), + "the key did not reach the environment of the client"); + } + server.releaseAll(); + const results = await Promise.all([apns.done, profiles.done]); + // Both consumers signed their requests with the key, so the handoff is + // what put the credential in front of the client. const signed = server.seen.filter((entry) => jwtKeyId(entry.authorization) === "SYNTHETICKEYID1"); - assert.ok(signed.length >= 2, `only ${signed.length} of the requests carried the staged key`); + assert.ok(signed.length >= 2, `only ${signed.length} of the requests carried the key`); assert.ok(server.seen.some((entry) => entry.url.startsWith("/v1/profiles?")), "the profile consumer never reached App Store Connect"); assert.ok(server.seen.some((entry) => entry.url.startsWith("/v1/bundleIds?")), @@ -395,6 +434,48 @@ test("the key never reaches argv and never reaches the environment file", async } assert.ok(!readFileSync(envFile, "utf8").includes(needle), "the key reached the environment file"); } finally { + server.releaseAll(); + apns.kill(); + profiles.kill(); + server.close(); + } +}); + +test("an unrelated secret in the environment does not reach the client", async () => { + // The child's environment is an allowlist: a wholesale copy of the + // parent's environment would hand every credential on the operator's + // machine to the client. + const home = join(root, "home-unrelated"); + mkdirSync(home, { recursive: true }); + const server = await localServer({ hold: true }); + const env = { + PATH: safePath, + HOME: home, + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem, + UNRELATED_RUNTIME_SECRET: unrelatedSecret + }; + const apns = spawnTracked(process.execPath, [apnsScript], env); + try { + await server.firstRequest; + const clientPids = await waitForClients([apns.pid]); + assert.ok(clientPids.length > 0, "the client never started"); + for (const pid of clientPids) { + const childEnvironment = processEnvironment(pid); + assert.ok(!childEnvironment.includes("UNRELATED_RUNTIME_SECRET"), + "an unrelated secret reached the client"); + assert.ok(!childEnvironment.includes(unrelatedSecret), + "the value of an unrelated secret reached the client"); + } + // The parent had it, so the check is not passing because the secret was + // never there. + assert.ok(processEnvironment(apns.pid).includes("UNRELATED_RUNTIME_SECRET"), + "the parent did not carry the unrelated secret"); + } finally { + server.releaseAll(); + apns.kill(); server.close(); } }); diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs index 14789343..992ed24f 100644 --- a/scripts/ios-fleet/test-asc-submit.mjs +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -51,14 +51,36 @@ const SUBMISSION_ID = "sub-1"; // does not stop at a rejected credential has somewhere to go // submissionHasNoPlatform: the draft submission carries no platform at all // submissionPlatform: the platform the named submission answers with +// platformPatchFails: the platform change is refused, which happens after +// the version was already bound +// platformPatchIgnored: the platform change is accepted but the read still +// answers with the platform the submission started with, so the change +// did not take +// platformReadBackMissing: the read of a draft answers with no platform at +// all +// createHidesPlatform: the created submission comes back with no platform +// bindFailsPlain: the binding is refused and names no blockers +// itemsFail: the read of the submission's items fails // platform: the platform the submission answers with function serve(options) { const patched = []; const bound = []; const created = []; + // The order the writes came in, so a test can see which one came first. + const order = []; // The platform each submission carries. A platform that was set on it is // remembered, so the read after the change answers with it. const platforms = new Map(); + // The platform a read of a submission answers with. A server that + // ignores a platform patch keeps answering with the platform the + // submission started with, and a server that has no platform at all + // answers with none: both are states the client's read-back guard has to + // catch, so the mock can play them. + const platformOf = (id) => { + if (options.platformReadBackMissing) return null; + if (options.platformPatchIgnored) return options.submissionPlatform || "IOS"; + return platforms.get(id) || options.submissionPlatform || "IOS"; + }; // A submission that has been marked submitted reads back as one, and a // draft reads back as a draft. The read before the submit and the read // after it are the same call, so what changed in between is what tells @@ -75,16 +97,59 @@ function serve(options) { const url = req.url.split("?")[0]; if (req.method === "PATCH" && url.startsWith("/v1/reviewSubmissions/")) { - const body = JSON.parse(raw || "{}"); - patched.push({ url, body }); - const attributes = body?.data?.attributes ?? {}; - const id = body?.data?.id || SUBMISSION_ID; + // The body is untrusted input, so it is parsed in a try block and + // validated before anything is stored. The shape is checked by + // hand, because the script has no dependencies to lean on. + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return json(400, { errors: [{ title: "the body is not JSON" }] }); + } + const isPlain = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + if (!isPlain(parsed) || !isPlain(parsed.data) || !isPlain(parsed.data.attributes)) { + return json(400, { errors: [{ title: "the body does not name a submission with attributes" }] }); + } + const id = parsed.data.id; + if (typeof id !== "string" || id.length === 0) { + return json(400, { errors: [{ title: "the body does not name a submission" }] }); + } + const attributes = parsed.data.attributes; + if (attributes.submitted !== undefined && typeof attributes.submitted !== "boolean") { + return json(400, { errors: [{ title: "submitted is not a boolean" }] }); + } + if (attributes.platform !== undefined && + (typeof attributes.platform !== "string" || attributes.platform.length === 0)) { + return json(400, { errors: [{ title: "platform is not a platform" }] }); + } + // Only the normalized values are kept: the id, the submitted flag + // and the platform, and nothing else the request carried. const submitted = attributes.submitted === true; + const platform = typeof attributes.platform === "string" ? attributes.platform : null; + patched.push({ + url, + body: { + data: { + type: "reviewSubmissions", + id, + attributes: { ...(submitted ? { submitted: true } : {}), ...(platform ? { platform } : {}) } + } + } + }); + order.push(submitted ? "submit" : "platform"); if (submitted) { sent.add(id); } - if (typeof attributes.platform === "string") { - platforms.set(id, attributes.platform); + if (platform !== null) { + platforms.set(id, platform); + } + if (!submitted && platform !== null && options.platformPatchFails) { + // The refusal of the platform change. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "the platform could not be set on the review submission." + }] }); } if (submitted && (options.submitFails || options.submitFailsPlain)) { if (options.submitFailsPlain) { @@ -120,15 +185,17 @@ function serve(options) { } }] }); } + const patchedPlatform = platformOf(id); + const patchedAttributes = { + state: submitted ? "WAITING_FOR_REVIEW" : "READY_FOR_REVIEW", + submittedDate: submitted ? "2026-10-20T00:00:00Z" : null + }; + if (patchedPlatform !== null) patchedAttributes.platform = patchedPlatform; return json(200, { data: { type: "reviewSubmissions", id, - attributes: { - platform: platforms.get(id) || options.submissionPlatform || "IOS", - state: submitted ? "WAITING_FOR_REVIEW" : "READY_FOR_REVIEW", - submittedDate: submitted ? "2026-10-20T00:00:00Z" : null - } + attributes: patchedAttributes } }); } @@ -229,7 +296,9 @@ function serve(options) { } const draft = (id) => { const attributes = { state: "READY_FOR_REVIEW", submittedDate: null }; - if (!options.submissionHasNoPlatform) { + // A service that answers a draft with no platform at all: the + // client has to set the platform and read it back. + if (!options.submissionHasNoPlatform && !options.platformReadBackMissing) { attributes.platform = options.submissionPlatform || "IOS"; } return { id, type: "reviewSubmissions", attributes }; @@ -241,14 +310,44 @@ function serve(options) { return json(200, { data }); } if (req.method === "POST" && url === "/v1/reviewSubmissions") { - const body = JSON.parse(raw || "{}"); - created.push(body); + // The body is untrusted input, so it is parsed in a try block and + // validated before anything is stored. A body that is not JSON, or + // that names no platform and no app, is refused with a 400 instead + // of throwing out of the handler. + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return json(400, { errors: [{ title: "the body is not JSON" }] }); + } + const data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.data : null; + const attributes = data && typeof data === "object" && !Array.isArray(data) ? data.attributes : null; + const platform = attributes && typeof attributes === "object" && !Array.isArray(attributes) + ? attributes.platform + : null; + const appId = data?.relationships?.app?.data?.id; + if (typeof platform !== "string" || platform.length === 0 || + typeof appId !== "string" || appId.length === 0) { + return json(400, { errors: [{ title: "the body does not name a platform and an app" }] }); + } + // Only the validated object is stored. + created.push({ + data: { + type: "reviewSubmissions", + attributes: { platform }, + relationships: { app: { data: { type: "apps", id: appId } } } + } + }); // The created submission carries the platform it was made with. - platforms.set("sub-new", body?.data?.attributes?.platform || "IOS"); + platforms.set("sub-new", platform); + const createdAttributes = { state: "READY_FOR_REVIEW", submittedDate: null }; + // A create response that does not echo the platform is a response + // the client cannot take the platform from. + if (!options.createHidesPlatform) createdAttributes.platform = platform; return json(201, { data: { type: "reviewSubmissions", id: "sub-new", - attributes: { platform: body?.data?.attributes?.platform || "IOS", state: "READY_FOR_REVIEW", submittedDate: null } + attributes: createdAttributes } }); } if (req.method === "POST" && url === "/v1/reviewSubmissionItems") { @@ -280,6 +379,7 @@ function serve(options) { } } }); + order.push("bind"); if (options.bindAuthFailsOnce && bound.length === 1) { // The refusal a rejected credential gets. The calls after this // one include the submit, which cannot be undone. @@ -289,6 +389,14 @@ function serve(options) { title: "Authentication credentials are missing or invalid." }] }); } + if (options.bindFailsPlain) { + // A refusal that names no blockers at all. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state." + }] }); + } if (options.bindFails) { // The refusal the live service answers with when the version is // not ready: the blockers are named, one entry per resource. @@ -318,6 +426,14 @@ function serve(options) { return json(201, { data: { type: "reviewSubmissionItems", id: "item-1" } }); } if (req.method === "GET" && /^\/v1\/reviewSubmissions\/[^/]+\/items$/.test(url)) { + if (options.itemsFail) { + // A read that fails: the run never gets as far as a submit. + return json(500, { errors: [{ + status: "500", + code: "INTERNAL_ERROR", + title: "the items could not be read." + }] }); + } if (options.boundTo === undefined) { // A submission with no items: the version is bound to nothing. return json(200, { data: [] }); @@ -345,23 +461,23 @@ function serve(options) { }); } // The read before the submission is touched: a draft, with the - // platform it carries. + // platform it carries. A server that has no platform at all + // answers with none, and the client has to catch that. + const draftPlatform = platformOf(id); + const draftAttributes = { state: "READY_FOR_REVIEW", submittedDate: null }; + if (draftPlatform !== null) draftAttributes.platform = draftPlatform; return json(200, { data: { type: "reviewSubmissions", id, - attributes: { - platform: platforms.get(id) || options.submissionPlatform || "IOS", - state: "READY_FOR_REVIEW", - submittedDate: null - } + attributes: draftAttributes } }); } return json(404, { errors: [{ title: "not found" }] }); }); }); - return { server, patched, bound, created }; + return { server, patched, bound, created, order }; } function submit(base, args = []) { @@ -389,11 +505,11 @@ function submit(base, args = []) { } async function withServer(options, fn) { - const { server, patched, bound, created } = serve(options); + const { server, patched, bound, created, order } = serve(options); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const base = `http://127.0.0.1:${server.address().port}`; try { - return await fn(base, patched, bound, created); + return await fn(base, patched, bound, created, order); } finally { server.close(); } @@ -777,3 +893,208 @@ test("the platform can be chosen, and a bad one is refused", async () => { assert.ok(/not a review platform/.test(stderr), `the refusal did not say what was wrong:\n${stderr}`); }); }); + +test("a refused submit binds the version once and stops the run", async () => { + // The submit was refused after the version was bound to a submission. A + // second candidate would be bound to the same version, and one version + // lives on one submission: the binding cannot be undone from the tool, + // so the run stops at the refusal instead of binding a second time. + await withServer({ ...READY, boundTo: undefined, submitFails: true, secondSubmission: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.equal(bound.length, 1, "the version was bound to more than one submission"); + assert.ok( + /is bound to review submission/.test(stderr), + `the run did not say that the version is bound:\n${stderr}` + ); + }); +}); + +test("a run that never submitted says that the version was never bound", async () => { + // The binding was refused and no submit was sent, so saying that App + // Store Connect refused a submission would be false: none was sent. + await withServer({ ...READY, boundTo: undefined, bindFailsPlain: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /the version was never bound to a submission/.test(stderr), + `the run did not say that nothing was bound:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /App Store Connect refused the submission/, + `the run said a submission was refused when none was sent:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the binding was refused"); + }); +}); + +test("a failed items read says that the version was never bound", async () => { + // The items could not be read, so the run never reached a binding and + // never reached a submit. + await withServer({ ...READY, itemsFail: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /the version was never bound to a submission/.test(stderr), + `the run did not say that nothing was bound:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /App Store Connect refused the submission/, + `the run said a submission was refused when none was sent:\n${stderr}` + ); + assert.equal(bound.length, 0, "the version was bound even though the items could not be read"); + }); +}); + +test("a refused platform change leaves the version unbound", async () => { + // The platform is proven before the binding, so a platform that cannot be + // set stops the run with the version on no submission at all: there is + // nothing for the operator to clear by hand. + await withServer({ ...READY, boundTo: undefined, submissionHasNoPlatform: true, platformPatchFails: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /the platform could not be set/, `the refusal did not name the platform:\n${stderr}`); + assert.match(stderr, /the version was not bound/, `the refusal did not say nothing was bound:\n${stderr}`); + assert.equal(bound.length, 0, "the version was bound even though the platform could not be set"); + assert.equal(patched.length, 1, "the submit was sent even though the platform could not be set"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + }); +}); + +test("the platform is proven before the version is bound", async () => { + // The order matters: the binding cannot be undone, so the platform is + // set and read back first. + await withServer({ ...READY, boundTo: undefined, submissionHasNoPlatform: true }, + async (base, patched, bound, created, order) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.deepEqual(order, ["platform", "bind", "submit"], "the platform was not proven before the binding"); + assert.equal(bound.length, 1, "the version was not bound"); + }); +}); + +test("a draft with no platform stops the submit", async () => { + // The read back after the platform change answers with no platform at + // all, so the platform could not be proven and the submit does not go + // out. + await withServer({ ...READY, boundTo: undefined, platformReadBackMissing: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match( + stderr, + /the platform on sub-1 is unreadable, not IOS/, + `the refusal did not name the platform:\n${stderr}` + ); + assert.equal(bound.length, 0, "the version was bound even though the platform was not proven"); + assert.equal(patched.length, 1, "the submit was sent even though the platform was not proven"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + }); +}); + +test("a platform that did not take stops the submit", async () => { + // The service accepted the platform change and then answered with the + // platform the submission had before, so the change did not take and the + // submit does not go out. + await withServer({ ...READY, boundTo: undefined, submissionPlatform: "MAC_OS", platformPatchIgnored: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID, "IOS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match( + stderr, + /the platform on sub-1 is MAC_OS, not IOS/, + `the refusal did not name the platform:\n${stderr}` + ); + assert.equal(bound.length, 0, "the version was bound even though the platform did not take"); + assert.equal(patched.length, 1, "the submit was sent even though the platform did not take"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + }); +}); + +test("a created submission with no platform in the response is set before the submit", async () => { + // The create response did not echo a platform. Recording the platform + // that was asked for would make the platform check pass, and the + // submission would go to review with a platform that was never proven. + await withServer({ ...READY, noSubmissions: true, boundTo: undefined, createHidesPlatform: true }, + async (base, patched, bound, created) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(created.length, 1, "no review submission was created"); + assert.equal(bound.length, 1, "the version was not bound to the new submission"); + assert.equal(patched.length, 2, `expected a platform patch and a submit patch, got ${patched.length}`); + assert.equal(patched[0].body?.data?.attributes?.platform, "IOS"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + assert.equal(patched[1].body?.data?.attributes?.submitted, true); + }); +}); + +test("the mock refuses a patch body that is not a submission", async () => { + // The mock is a trust boundary too: an inbound body is validated before + // it is stored, and a malformed one is refused with a 400 instead of + // throwing out of the handler. + const { server } = serve({ ...READY }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const patch = (body) => new Promise((resolve) => { + const request = fetch(`${base}/v1/reviewSubmissions/sub-1`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(2000) + }); + request.then((response) => resolve(response.status)).catch(() => resolve(0)); + }); + try { + assert.equal(await patch("{not json"), 400, "a body that is not JSON was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "sub-1" } })), 400, + "a body without attributes was accepted"); + assert.equal(await patch(JSON.stringify({ data: { attributes: { platform: "IOS" } } })), 400, + "a body without an id was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "", attributes: {} } })), 400, + "a body with an empty id was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "sub-1", attributes: { submitted: "yes" } } })), 400, + "a body with a submitted flag that is not a boolean was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "sub-1", attributes: { platform: "" } } })), 400, + "a body with an empty platform was accepted"); + assert.equal(await patch(JSON.stringify([1, 2, 3])), 400, "a body that is not an object was accepted"); + } finally { + server.close(); + } +}); + +test("the mock refuses a create body that names no platform and no app", async () => { + // The mock is a trust boundary too: an inbound body is validated before + // it is stored, and a malformed one is refused with a 400 instead of + // throwing out of the handler. + const { server, created } = serve({ ...READY }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const post = (body) => new Promise((resolve) => { + const request = fetch(`${base}/v1/reviewSubmissions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(2000) + }); + request.then((response) => resolve(response.status)).catch(() => resolve(0)); + }); + try { + assert.equal(await post("{not json"), 400, "a body that is not JSON was accepted"); + assert.equal(await post(JSON.stringify({ data: { attributes: {} } })), 400, + "a body without a platform was accepted"); + assert.equal(await post(JSON.stringify({ data: { attributes: { platform: "IOS" } } })), 400, + "a body without an app was accepted"); + assert.equal(await post(JSON.stringify({ data: { attributes: { platform: "IOS" }, + relationships: { app: { data: { id: "" } } } } })), 400, + "a body with an empty app id was accepted"); + assert.equal(created.length, 0, "a refused body was stored"); + } finally { + server.close(); + } +}); diff --git a/scripts/ios-install-appstore-profiles.sh b/scripts/ios-install-appstore-profiles.sh index 72df7014..42fda767 100755 --- a/scripts/ios-install-appstore-profiles.sh +++ b/scripts/ios-install-appstore-profiles.sh @@ -8,7 +8,6 @@ umask 077 ROOT="$(cd "$(dirname "$0")/.." && pwd)" MAP="${ROOT}/ios/CodeCapsCompanion/appstore-profiles.json" CLIENT="${ROOT}/scripts/ios-fleet/asc-api.mjs" -KEY_READER="${ROOT}/scripts/ios-fleet/asc-key.mjs" DEST="${HOME}/Library/MobileDevice/Provisioning Profiles" WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/cc-profiles.XXXXXX")" trap 'rm -rf "$WORKDIR"' EXIT @@ -21,21 +20,42 @@ chmod 700 "$DEST" } # The client takes the key from the environment, because a path on disk is not -# an approved secret source (Kody rule 42). The hosted runner stages the key -# to a mode-600 file and publishes only its path, so the staged key is handed -# to the child in the child's environment by the hardened reader. The key -# never reaches a command line, a log, the environment file, or a temporary -# file, and it never reaches this shell: the reader keeps it in the file and -# puts it in the child's environment. +# an approved secret source (Kody rule 42). The only approved sources are +# process.env.ASC_KEY_P8 and `fleet-secrets get ASC_KEY_P8`, and the client +# resolves both on its own. A named ASC_KEY_PATH is not a credential source +# at all and is not read here, so a stale or foreign path can never decide +# which credential signs the request. # -# A path that names nothing is not a credential: the run falls through and the -# client resolves the credential the way it does today, from an exported -# ASC_KEY_P8 or from fleet-secrets. +# The key never reaches a command line, a log, the environment file, or a +# temporary file. A run that resolves no credential at all is a hard stop, +# and it names the variables that could not be resolved, so the operator is +# told which one to set instead of being left with a permission message that +# points at nothing. run_client() { - if [[ -n "${ASC_KEY_PATH:-}" ]]; then - node "$KEY_READER" exec "$ASC_KEY_PATH" -- node "$CLIENT" "$@" - else - node "$CLIENT" "$@" + local rc=0 err="$WORKDIR/client.stderr" + node "$CLIENT" "$@" 2>"$err" || rc=$? + show_client_stderr "$err" + name_missing_credential "$err" + return "$rc" +} + +# Prints what the client said, so nothing it reported is lost when its +# stderr is captured. +show_client_stderr() { + local err="$1" + if [[ -s "$err" ]]; then + cat "$err" >&2 + fi +} + +# Names the variables that could not be resolved when the client found no +# credential. The client says which sources it tried; this says which +# variables to set, so the failure is actionable instead of a permission +# message that points at nothing. +name_missing_credential() { + local err="$1" + if grep -q "no App Store Connect credential found" "$err" 2>/dev/null; then + echo "error: no App Store Connect credential resolved: set ASC_KEY_P8 with ASC_KEY_ID and ASC_ISSUER_ID, or let fleet-secrets answer for Fleet-OPS prod /asc" >&2 fi } From a90d26689e65efbd7d935e9a270b69bf85253101 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:25:10 -0500 Subject: [PATCH 47/50] The caption carries the qualifier, so a surface cannot leave it out Jet reviewed the qualifier work and found the rendered acceptance incomplete: the provider focus captions and the lock screen's two window captions built a caption row without the qualifier, so a bar that was meant to read "7d weekly" read "7d". The shared row file passed the qualifier and these three surfaces did not. The qualifier was an argument with a default, so a surface that rendered a window's caption could leave it out and nothing said so. It is a value now. `QuotaWindowCaption` is shared with the widget and the tests, a caption carries its qualifier, a caption built from a window carries that window's, and a caption row takes a caption. The only way to have a caption with no qualifier is to ask for one by name, so the omission is visible where it happens. The four surfaces that dropped it now print the window's caption: the provider focus per window caption, the at cap caption, and both of the lock screen's two window captions. The test starts from the bytes the backend sends. The earlier fixture was a custom struct calling the vocabulary helpers, so it could not catch a missing argument; this one decodes a real Muse Assist payload, derives the bars the way the widget derives them, and takes the qualifiers from the production resolution, then asserts the caption carries them. The iOS parser and the SwiftUI render are still in the iOS target, which has no test target, so the type is what holds those two links. This also lands the vocabulary work that was still uncommitted: an extra allowance joins the group of the one sibling that has a period, and the canonical forms the widget renders resolve their period through whole tokens, so a bar that prints "7d" is not split from the sibling that prints the same token. Five hundred and seventy-eight QuotaCore tests, green, and the iOS app and widget build. --- Sources/QuotaCore/QuotaWindowCaption.swift | 47 +++++ Sources/QuotaCore/QuotaWindowVocabulary.swift | 23 +++ .../QuotaWindowCaptionTests.swift | 164 ++++++++++++++++++ .../WidgetPresentationVocabularyTests.swift | 50 ++++++ .../project.pbxproj | 126 +++++++++++++- .../Widgets/WidgetPresentation.swift | 13 ++ .../Widgets/WidgetViews.swift | 33 ++-- .../Widgets/WidgetWindowRows.swift | 47 +++-- ios/CodeCapsCompanion/project.yml | 4 + 9 files changed, 462 insertions(+), 45 deletions(-) create mode 100644 Sources/QuotaCore/QuotaWindowCaption.swift create mode 100644 Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift diff --git a/Sources/QuotaCore/QuotaWindowCaption.swift b/Sources/QuotaCore/QuotaWindowCaption.swift new file mode 100644 index 00000000..0b514fbb --- /dev/null +++ b/Sources/QuotaCore/QuotaWindowCaption.swift @@ -0,0 +1,47 @@ +import Foundation + +/// What a window's caption says. +/// +/// A caption is three parts: the cadence token beside the bar, the qualifier that +/// tells two bars of the same cadence apart, and the reset line. +/// +/// The qualifier is the part that used to go missing. A caption row built without it +/// printed a bare "7d" where the row was meant to read "7d weekly", and nothing in +/// the type said so: the qualifier was an argument with a default, so a surface +/// that rendered a window's caption could leave it out and the omission showed up +/// only in a review. +/// +/// The type is shared so the widget and the tests use the same one. A caption cannot +/// be built without stating its qualifier, and a surface that renders a window's +/// caption builds it from that window, so a qualifier cannot quietly go missing +/// again. +/// +/// Plain Foundation; no other QuotaCore type. +public struct QuotaWindowCaption: Equatable, Sendable { + /// The cadence token: "5h", "7d", "1m". + public let token: String + /// The word that tells two bars of the same cadence apart: "weekly", + /// "additional". Empty when the bar has no sibling to tell apart from. + public let qualifier: String + /// The reset line: "Resets in 12d 22m". + public let resetCaption: String + + public init(token: String, qualifier: String, resetCaption: String) { + self.token = token + self.qualifier = qualifier + self.resetCaption = resetCaption + } + + /// The caption of a bar that has no sibling, and so no qualifier to print. + /// + /// It is a separate name on purpose: a caller that has a window uses the + /// window's own caption, and a caller that has no window says so here. + public static func withoutQualifier(token: String, resetCaption: String) -> QuotaWindowCaption { + QuotaWindowCaption(token: token, qualifier: "", resetCaption: resetCaption) + } + + /// Whether the caption says anything at all. + public var isEmpty: Bool { + token.isEmpty && qualifier.isEmpty && resetCaption.isEmpty + } +} diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index 2feca406..e32adc88 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -115,6 +115,11 @@ public enum QuotaWindowVocabulary { /// (Kody rule 40, binding 2026-10-21). A label resolves a group only /// through whole lexical tokens, below. /// + /// The canonical forms the widget renders โ€” "7-day window", "5-hour + /// window", "1-day window", "1-month window" โ€” name their period through + /// whole tokens too, so a bar whose own cadence does not resolve still + /// groups with the sibling that prints the same caption token. + /// /// An extra allowance is extra capacity of the period it doubles, so it /// groups with that window rather than standing alone. It carries no /// cadence of its own, so its label is left out of the lookup rather @@ -129,6 +134,11 @@ public enum QuotaWindowVocabulary { label: "" ) if canonical != otherLimit { return canonical } + // An extra allowance names no period of its own, and its label is + // left out of the lookup: it joins the group of the one sibling + // that has a period, which is what makes the owner-approved pair + // ("weekly" / "additional") reachable. + if isExtraAllowance { return otherLimit } // The label resolves a group only through whole lexical tokens: // "weekly" names the weekly group, "notweekly" names no group at all. let tokens = lexicalTokens(label) @@ -136,6 +146,19 @@ public enum QuotaWindowVocabulary { if tokens.contains("monthly") || tokens.contains("billing") { return oneMonth } if tokens.contains("daily") { return oneDay } if tokens.contains("hourly") { return fiveHour } + // The canonical forms the widget renders still name their period: + // "7-day window", "5-hour window", "1-day window", "1-month window". + // The caption token beside the bar resolves the same form through + // the prose matcher, so the group has to resolve it too, or a bar + // that prints "7d" is grouped as "Other limit" and split from the + // sibling that prints the same token. + let hasDay = tokens.contains("day") || tokens.contains("days") + let hasHour = tokens.contains("hour") || tokens.contains("hours") + let hasMonth = tokens.contains("month") || tokens.contains("months") + if tokens.contains("7"), hasDay { return sevenDay } + if tokens.contains("1"), hasDay { return oneDay } + if tokens.contains("5"), hasHour { return fiveHour } + if tokens.contains("1"), hasMonth { return oneMonth } return canonical } diff --git a/Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift b/Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift new file mode 100644 index 00000000..c9cc274b --- /dev/null +++ b/Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import QuotaCore + +/// The caption a bar prints, starting from the bytes the backend sends. +/// +/// The earlier fixture was a custom Swift struct that called the vocabulary helpers +/// directly, so it could not catch a surface that left the qualifier out: it never +/// went near a payload, a decode, or a caption. This one starts from the payload +/// the backend sends for a Muse Assist plan, decodes it, derives the bars the way +/// the widget derives them, and takes the qualifiers from the production +/// resolution. The caption that comes out carries the qualifier, which is what the +/// widget prints beside the bar. +/// +/// The two links this cannot cover are the iOS parser and the SwiftUI render: both +/// live in the iOS target, which has no test target. What holds them is the type. +/// `QuotaWindowCaption` carries its qualifier, a caption built from a window carries +/// that window's, and a caption row takes a caption, so a surface that renders a +/// window's caption cannot leave the qualifier out without saying so. +final class QuotaWindowCaptionTests: XCTestCase { + /// A Muse Assist plan as the backend sends it. + /// + /// The weekly limit arrives with a "weekly" token. The extra allowance arrives + /// with no token at all and with the label the label pipeline has already + /// rewritten to the catch-all "Other limit" copy, so nothing in the label says + /// what kind of window it is: the provider and the missing token are the whole + /// of the signal. + private static let museAssistPayload = """ + { + "windows": [ + { + "id": "muse-weekly", + "provider": "muse-assist", + "providerKey": "muse-assist", + "label": "Weekly limit", + "window": "weekly", + "remainingPercent": 80 + }, + { + "id": "muse-extra", + "provider": "muse-assist", + "providerKey": "muse-assist", + "label": "Other limit", + "window": null, + "remainingPercent": 60 + } + ] + } + """.data(using: .utf8)! + + /// The wire shape, as the backend sends it. + private struct WireWindow: Decodable { + let id: String + let provider: String + let providerKey: String? + let label: String + let window: String? + let remainingPercent: Double? + } + + private struct WireEnvelope: Decodable { + let windows: [WireWindow]? + } + + /// The bar the widget draws for a decoded window. + /// + /// The cadence and the extra-allowance identity come from the production + /// vocabulary, the way the widget's parser asks for them. + private func bar(for window: WireWindow) -> QuotaWindowVocabulary.BarCaption { + QuotaWindowVocabulary.BarCaption( + cadence: QuotaWindowVocabulary.standardCadence( + windowToken: window.window, + label: window.label + ), + label: window.label, + isExtraAllowance: QuotaWindowVocabulary.isExtraAllowance( + providerKey: window.providerKey, + label: window.label, + windowToken: window.window + ) + ) + } + + /// The caption the widget prints for a decoded window. + private func caption(for window: WireWindow, qualifier: String) -> QuotaWindowCaption { + QuotaWindowCaption( + token: QuotaWindowVocabulary.popoverCaption( + windowToken: window.window, + label: window.label + ), + qualifier: qualifier, + resetCaption: "" + ) + } + + /// A payload's bars resolve to the owner-approved pair. + /// + /// The weekly limit reads "weekly" and the extra allowance reads "additional", + /// and the extra allowance carries no token of its own, so the qualifier is the + /// only thing that tells the two bars apart. + func test_a_payload_resolves_to_the_owner_approved_pair() throws { + let envelope = try JSONDecoder().decode(WireEnvelope.self, from: Self.museAssistPayload) + let windows = try XCTUnwrap(envelope.windows) + XCTAssertEqual(windows.count, 2) + + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: windows.map { bar(for: $0) }) + XCTAssertEqual(qualifiers.count, 2) + XCTAssertEqual(qualifiers[0], QuotaWindowVocabulary.barQualifierWeekly) + XCTAssertEqual(qualifiers[1], QuotaWindowVocabulary.barQualifierAdditional) + } + + /// The caption a bar prints carries the qualifier the payload resolved. + /// + /// This is the link that was missing: a caption row built from the window's + /// caption prints the qualifier, and a caption built without one does not, so + /// the two are different values and a surface cannot confuse them. + func test_the_caption_carries_the_qualifier_the_payload_resolved() throws { + let envelope = try JSONDecoder().decode(WireEnvelope.self, from: Self.museAssistPayload) + let windows = try XCTUnwrap(envelope.windows) + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: windows.map { bar(for: $0) }) + let captions = zip(windows, qualifiers).map { caption(for: $0, qualifier: $1) } + + XCTAssertEqual(captions[0].qualifier, "weekly") + XCTAssertEqual(captions[1].qualifier, "additional") + // The two captions differ in the qualifier alone, which is what tells the + // two bars apart. + XCTAssertNotEqual(captions[0].qualifier, captions[1].qualifier) + } + + /// A lone bar prints no qualifier, and the caption says so. + func test_a_lone_bar_prints_no_qualifier() throws { + let envelope = try JSONDecoder().decode(WireEnvelope.self, from: Self.museAssistPayload) + let windows = try XCTUnwrap(envelope.windows) + let lone = try XCTUnwrap(windows.first) + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: [bar(for: lone)]) + XCTAssertEqual(qualifiers, [""]) + XCTAssertEqual(caption(for: lone, qualifier: qualifiers[0]).qualifier, "") + } + + /// A caption says whether it has anything to print. + func test_an_empty_caption_is_empty() { + XCTAssertTrue(QuotaWindowCaption(token: "", qualifier: "", resetCaption: "").isEmpty) + XCTAssertFalse( + QuotaWindowCaption(token: "7d", qualifier: "", resetCaption: "").isEmpty + ) + XCTAssertFalse( + QuotaWindowCaption(token: "", qualifier: "weekly", resetCaption: "").isEmpty + ) + } + + /// The only way to have a caption without a qualifier is to ask for one. + /// + /// The qualifier used to be an argument with a default, so a surface could leave + /// it out. Now a caption states its qualifier, and the no-qualifier form has + /// its own name, so the omission is visible in the code that makes it. + func test_a_caption_without_a_qualifier_is_asked_for_by_name() { + let caption = QuotaWindowCaption.withoutQualifier( + token: "7d", + resetCaption: "Resets in 3h" + ) + XCTAssertEqual(caption.qualifier, "") + XCTAssertEqual(caption.token, "7d") + XCTAssertEqual(caption.resetCaption, "Resets in 3h") + } +} diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index 0ac55cf7..94b17899 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -476,4 +476,54 @@ final class WidgetPresentationVocabularyTests: XCTestCase { ]) XCTAssertEqual(qualifiers, [""]) } + + /// An extra allowance names no period of its own, so its label is left out + /// of the group lookup: a label that happens to name a period must not + /// give the extra allowance a group of its own, or it stops joining the + /// sibling it doubles (Kody review 2026-10-21). + func testExtraAllowanceLabelIsLeftOutOfTheGroupLookup() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup( + cadence: QuotaWindowVocabulary.otherLimit, + label: "7-day window", + isExtraAllowance: true + ), + QuotaWindowVocabulary.otherLimit, + "an extra allowance carries no period of its own, so its label does not name one." + ) + // The same label on a bar that is not an extra allowance does name the + // period: the rule is about the extra allowance, not about the label. + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup( + cadence: QuotaWindowVocabulary.otherLimit, + label: "7-day window" + ), + QuotaWindowVocabulary.sevenDay + ) + } + + /// The group a bar belongs to and the caption token beside it have to name + /// the same period: a bar that prints "7d" is grouped as the 7-day group + /// and not as "Other limit", so it is not split from the sibling that + /// prints the same token (Kody review 2026-10-21). + func testTheGroupAndTheCaptionTokenNameTheSamePeriod() { + let cases: [(label: String, group: String, token: String)] = [ + ("7-day window", QuotaWindowVocabulary.sevenDay, "7d"), + ("5-hour window", QuotaWindowVocabulary.fiveHour, "5h"), + ("1-day window", QuotaWindowVocabulary.oneDay, "1d"), + ("1-month window", QuotaWindowVocabulary.oneMonth, "1m") + ] + for testCase in cases { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: testCase.label), + testCase.group, + "the canonical label '\(testCase.label)' names its period." + ) + XCTAssertEqual( + QuotaWindowVocabulary.popoverCaption(windowToken: "", label: testCase.label), + testCase.token, + "the caption token beside '\(testCase.label)' names the same period." + ) + } + } } diff --git a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj index 33e70904..52aa215c 100644 --- a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj +++ b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj @@ -20,43 +20,66 @@ 1D4D70BAAB091F8992DB1693 /* ResetAlarmCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */; }; 1E390605C8A820FFB895AA0F /* alarm-tink.wav in Resources */ = {isa = PBXBuildFile; fileRef = 4FE56A36DCE8762AD17C6776 /* alarm-tink.wav */; }; 1F27C36495D3FB241A242476 /* alarm-blow.wav in Resources */ = {isa = PBXBuildFile; fileRef = 0638A6007D4E7A392FD7D595 /* alarm-blow.wav */; }; + 1FFA2ED8A54A8F8FDE6C2226 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B67B47EAC3ED255FDB0018 /* PrivacyInfo.xcprivacy */; }; 2018A85587B07653EFF5445C /* CompanionContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C4C2C5DC3AA20234129F15 /* CompanionContentView.swift */; }; 2300043111EDF02526E8D53B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DF46F01EE0931315955EE9AF /* Assets.xcassets */; }; 238EE47D0BA13D2064CE6DE6 /* alarm-tink.wav in Resources */ = {isa = PBXBuildFile; fileRef = 4FE56A36DCE8762AD17C6776 /* alarm-tink.wav */; }; 2440D143F835BAD99240BE8D /* CompanionContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C4C2C5DC3AA20234129F15 /* CompanionContentView.swift */; }; + 28EECA14D0CD8CE69B1250B6 /* QuotaWindowVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */; }; 2AA3DE1AA8DD20DD5AD6E403 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A40D20353A8D6C1D9E9D349 /* WidgetViews.swift */; }; 2CA370B5E2149BEA3FCB3F6C /* CodeCapsCompanionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CE8BC7DF9DCF088BF1B805 /* CodeCapsCompanionApp.swift */; }; 311AB846015F2FEDF0014C43 /* CompanionTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1CF45BE162CC9B8CD968DC /* CompanionTheme.swift */; }; 31CFC3813E00700F4A1D0E2F /* ResetAlarmCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */; }; + 33D335245DED8229722048BA /* WidgetPlatformDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */; }; 346906A2DDABDF60AF9D4F62 /* alarm-submarine.wav in Resources */ = {isa = PBXBuildFile; fileRef = 5F63EE805947E5707E2A0C3F /* alarm-submarine.wav */; }; + 3AB7A6CDA67C38224B7CBE08 /* QuotaWindowCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */; }; 3C07D53C4EFEE8F91EEDDEC7 /* ResetAlarmCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */; }; + 41F6AE401AE48C11EDDA4B80 /* SentryTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C98744F4C98CE0C6227834 /* SentryTelemetry.swift */; }; + 4A84751C143F11619F941B8C /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 897AB651A949F8B5454F5C3C /* Sentry */; }; + 4BBB6BFF85C4F547B32A3035 /* QuotaWindowCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */; }; 4D9258D010AD16F059277D93 /* alarm-submarine.wav in Resources */ = {isa = PBXBuildFile; fileRef = 5F63EE805947E5707E2A0C3F /* alarm-submarine.wav */; }; 4F76A47C0A1C84A3525F004C /* CompanionReadTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */; }; + 500162E6C6D0CFA94D715BD0 /* FleetSourceDisagreement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */; }; + 55B1F0DECD506D0BACFFBBB7 /* RemotePushRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A736FD344F79D520A16227B3 /* RemotePushRegistration.swift */; }; + 5EF6E3804FDCF37670BB45C0 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 07191D727884C7621506D926 /* Sentry */; }; 6447965E07907E9AA3407123 /* alarm-sosumi.wav in Resources */ = {isa = PBXBuildFile; fileRef = 4FA72166D5E5A0F726B4DF18 /* alarm-sosumi.wav */; }; + 67E08982F84A1E831F95464B /* FleetSourceDisagreement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */; }; 689104F1DF3DDC7C7F75D168 /* WidgetSnapshotFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE3A7D9C36FF286A082F60E /* WidgetSnapshotFetcher.swift */; }; 6AF2CF64E8C9F39D87C4B24C /* CompanionReadTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */; }; + 6BF5A6C33EC9F93F7354379F /* UsageAlertPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E8877EA13789E3986BFEF9 /* UsageAlertPreferences.swift */; }; 717D1C459A127B80594F8905 /* WidgetWindowRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A7EF5A19DFD2299052F4DE /* WidgetWindowRows.swift */; }; 7A59C4CE14A8C4532A6C9327 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DF46F01EE0931315955EE9AF /* Assets.xcassets */; }; + 7DA53137FC1697E13889298B /* QuotaWindowVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */; }; 7DFD68E2400E127E11B80471 /* CodeCapsWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FD27E95016C7BDDE19F6C6E2 /* CodeCapsWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7F3D6663EA86FDE73EA17DCB /* alarm-sosumi.wav in Resources */ = {isa = PBXBuildFile; fileRef = 4FA72166D5E5A0F726B4DF18 /* alarm-sosumi.wav */; }; + 80AFBDA4491ADD0F917DEF11 /* FleetSourceDisagreement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */; }; 831B4D460D912E65E3D869D3 /* ResetAlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A5807594DFD989CADCC6F2 /* ResetAlarmSound.swift */; }; + 8A473847ACFCA158CD1E9386 /* QuotaWindowVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */; }; 99A788DAEA41B4AB83A93501 /* CodeCapsCompanionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CE8BC7DF9DCF088BF1B805 /* CodeCapsCompanionApp.swift */; }; 99E4B46415A2BCB252FAA6C0 /* ResetAlarmMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ECFD3E9DC2728ACB5FB5488 /* ResetAlarmMessage.swift */; }; 9D826DF8BA1D0C240F17EAFC /* ResetAlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A5807594DFD989CADCC6F2 /* ResetAlarmSound.swift */; }; + 9DCBAAF5AED382505D06C03D /* RemotePushRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A736FD344F79D520A16227B3 /* RemotePushRegistration.swift */; }; + A27D61CE3DC2A52A0B83E662 /* WidgetPlatformDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */; }; + A82BDBD544318CBB2DC1FE96 /* QuotaWindowVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */; }; A90E0EF383B2CDF27263BAF0 /* WidgetConfigurationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72DB68669C8EFB8E9045184C /* WidgetConfigurationIntent.swift */; }; AB8C667473DF14CCF7B48F2A /* WidgetDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F281E65EF7010842967CD100 /* WidgetDataProvider.swift */; }; AFBBFB2FD8EEA9DA9EF40042 /* alarm-bottle.wav in Resources */ = {isa = PBXBuildFile; fileRef = 302C44DD0C87899C354A914C /* alarm-bottle.wav */; }; BBE95186FD35EC37DBF13D51 /* ResetAlarmCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */; }; BD4C31C68641BA2ED6BE8798 /* alarm-bottle.wav in Resources */ = {isa = PBXBuildFile; fileRef = 302C44DD0C87899C354A914C /* alarm-bottle.wav */; }; + BE0C14400B9E798CA08168F7 /* WidgetPlatformDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */; }; BF0A24BA7D7B70B182950DA9 /* ResetAlarmTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FD1EFF75431229A669FC5A /* ResetAlarmTracker.swift */; }; C1B3C6C3651687B8283C75CD /* WidgetSnapshotFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE3A7D9C36FF286A082F60E /* WidgetSnapshotFetcher.swift */; }; C23393B11365D16E8ECA5D8E /* alarm-blow.wav in Resources */ = {isa = PBXBuildFile; fileRef = 0638A6007D4E7A392FD7D595 /* alarm-blow.wav */; }; C48D9F8052E4F39044504C84 /* CompanionQuotaModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1371DB889435F70346FB6AF /* CompanionQuotaModel.swift */; }; C7498C0ACDD5320564DE32B0 /* WidgetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716F800EA9DEDFC597BD08BB /* WidgetPresentation.swift */; }; + CADE19D9C4D7A21C22D894AC /* UsageAlertPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E8877EA13789E3986BFEF9 /* UsageAlertPreferences.swift */; }; D3C30EA2E4A6B59A22E086DB /* WidgetDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F281E65EF7010842967CD100 /* WidgetDataProvider.swift */; }; + D403E6C85AA58126F1F15557 /* WidgetPlatformDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */; }; + D88D34F03E978D88086FFCAE /* SentryTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C98744F4C98CE0C6227834 /* SentryTelemetry.swift */; }; DA221384FC017A40031C0446 /* ResetAlarmMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ECFD3E9DC2728ACB5FB5488 /* ResetAlarmMessage.swift */; }; DD572CC08FCFC7414D7236B7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DF46F01EE0931315955EE9AF /* Assets.xcassets */; }; E0EE20CDC3987E143A3FCB48 /* ResetAlarmTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FD1EFF75431229A669FC5A /* ResetAlarmTracker.swift */; }; + E38B758D8183E576DEE41A6C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B67B47EAC3ED255FDB0018 /* PrivacyInfo.xcprivacy */; }; E4F68F451FCE5A7069DA052D /* alarm-frog.wav in Resources */ = {isa = PBXBuildFile; fileRef = 881BAB30A7762A0170BE4FD8 /* alarm-frog.wav */; }; E77E1CAAEDCB9C3AAD706144 /* alarm-frog.wav in Resources */ = {isa = PBXBuildFile; fileRef = 881BAB30A7762A0170BE4FD8 /* alarm-frog.wav */; }; E8DE0A145EA5D76CD51886E8 /* CompanionReadTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */; }; @@ -64,6 +87,7 @@ EA984A6E75520CAB2639A8B4 /* CodeCapsWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6316FA88D130531D99C73AEE /* CodeCapsWidgetBundle.swift */; }; F245CD4AA6FA8D9C23A5D373 /* CompanionTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1CF45BE162CC9B8CD968DC /* CompanionTheme.swift */; }; F3C6891672DADC0AD9667E90 /* WidgetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716F800EA9DEDFC597BD08BB /* WidgetPresentation.swift */; }; + F4FCB18D25C83B7AAD11C565 /* FleetSourceDisagreement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */; }; F912D93277350586352FE398 /* WidgetWindowRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A7EF5A19DFD2299052F4DE /* WidgetWindowRows.swift */; }; FC810FA7900F297E014083A5 /* AlarmSoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674BB9709E48A6D00D4EE92C /* AlarmSoundPlayer.swift */; }; FE52A4A8D224D9DBB320ABCC /* alarm-glass.wav in Resources */ = {isa = PBXBuildFile; fileRef = 185B2FA090E97F0B48E3E9A8 /* alarm-glass.wav */; }; @@ -114,34 +138,42 @@ /* Begin PBXFileReference section */ 0638A6007D4E7A392FD7D595 /* alarm-blow.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-blow.wav"; sourceTree = ""; }; 0A40D20353A8D6C1D9E9D349 /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = ""; }; + 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuotaWindowCaption.swift; path = ../../Sources/QuotaCore/QuotaWindowCaption.swift; sourceTree = ""; }; 10FF587176549AAC4A616E3B /* InfoMac.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = InfoMac.plist; sourceTree = ""; }; + 13B67B47EAC3ED255FDB0018 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 154D17A5A492536F43729222 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 185B2FA090E97F0B48E3E9A8 /* alarm-glass.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-glass.wav"; sourceTree = ""; }; 1981252E1F614DC809CAB013 /* CodeCapsWidgetsMac.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodeCapsWidgetsMac.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuotaWindowVocabulary.swift; path = ../../Sources/QuotaCore/QuotaWindowVocabulary.swift; sourceTree = ""; }; 1C1CF45BE162CC9B8CD968DC /* CompanionTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionTheme.swift; sourceTree = ""; }; 302C44DD0C87899C354A914C /* alarm-bottle.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-bottle.wav"; sourceTree = ""; }; 34CE8BC7DF9DCF088BF1B805 /* CodeCapsCompanionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeCapsCompanionApp.swift; sourceTree = ""; }; 4FA72166D5E5A0F726B4DF18 /* alarm-sosumi.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-sosumi.wav"; sourceTree = ""; }; 4FE56A36DCE8762AD17C6776 /* alarm-tink.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-tink.wav"; sourceTree = ""; }; 5087F87CE3B2EDD3B9E1309C /* CodeCapsWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeCapsWidgets.entitlements; sourceTree = ""; }; + 50C98744F4C98CE0C6227834 /* SentryTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTelemetry.swift; sourceTree = ""; }; 5ECFD3E9DC2728ACB5FB5488 /* ResetAlarmMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ResetAlarmMessage.swift; path = ../../Sources/QuotaCore/ResetAlarmMessage.swift; sourceTree = ""; }; 5F63EE805947E5707E2A0C3F /* alarm-submarine.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-submarine.wav"; sourceTree = ""; }; 6316FA88D130531D99C73AEE /* CodeCapsWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeCapsWidgetBundle.swift; sourceTree = ""; }; 674BB9709E48A6D00D4EE92C /* AlarmSoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSoundPlayer.swift; sourceTree = ""; }; 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ResetAlarmCadence.swift; path = ../../Sources/QuotaCore/ResetAlarmCadence.swift; sourceTree = ""; }; + 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WidgetPlatformDisplay.swift; path = ../../Sources/QuotaCore/WidgetPlatformDisplay.swift; sourceTree = ""; }; 716F800EA9DEDFC597BD08BB /* WidgetPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetPresentation.swift; sourceTree = ""; }; 72DB68669C8EFB8E9045184C /* WidgetConfigurationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetConfigurationIntent.swift; sourceTree = ""; }; 84C4C2C5DC3AA20234129F15 /* CompanionContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionContentView.swift; sourceTree = ""; }; 881BAB30A7762A0170BE4FD8 /* alarm-frog.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-frog.wav"; sourceTree = ""; }; 89A5807594DFD989CADCC6F2 /* ResetAlarmSound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetAlarmSound.swift; sourceTree = ""; }; 9DE3A7D9C36FF286A082F60E /* WidgetSnapshotFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WidgetSnapshotFetcher.swift; path = ../../Sources/QuotaCore/WidgetSnapshotFetcher.swift; sourceTree = ""; }; + A736FD344F79D520A16227B3 /* RemotePushRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePushRegistration.swift; sourceTree = ""; }; B47E0485E1902045DBFE06B1 /* CodeCapsCompanion.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeCapsCompanion.entitlements; sourceTree = ""; }; C1371DB889435F70346FB6AF /* CompanionQuotaModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionQuotaModel.swift; sourceTree = ""; }; C2EA171A442F77D9348C68D4 /* InfoMac.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = InfoMac.plist; sourceTree = ""; }; C679EF2BE96C94E938E56DC1 /* CodeCapsCompanionMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeCapsCompanionMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FleetSourceDisagreement.swift; path = ../../Sources/QuotaCore/FleetSourceDisagreement.swift; sourceTree = ""; }; D5FD1EFF75431229A669FC5A /* ResetAlarmTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ResetAlarmTracker.swift; path = ../../Sources/QuotaCore/ResetAlarmTracker.swift; sourceTree = ""; }; D7A7EF5A19DFD2299052F4DE /* WidgetWindowRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetWindowRows.swift; sourceTree = ""; }; D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompanionReadTokenStore.swift; path = ../../Sources/QuotaCore/CompanionReadTokenStore.swift; sourceTree = ""; }; + D9E8877EA13789E3986BFEF9 /* UsageAlertPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UsageAlertPreferences.swift; path = ../../Sources/QuotaCore/UsageAlertPreferences.swift; sourceTree = ""; }; DF46F01EE0931315955EE9AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E124B68EB2FF725D59E2EC1F /* SentenceGap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceGap.swift; sourceTree = ""; }; E53A47DB2534669DD3BEE62E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -150,6 +182,25 @@ FFD1407F089F595B7B485517 /* CodeCapsCompanion.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CodeCapsCompanion.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 1D9F2C453D32145A481FD28E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5EF6E3804FDCF37670BB45C0 /* Sentry in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F9880B8D20C15C76764708DB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4A84751C143F11619F941B8C /* Sentry in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 1A10513D0EF01D22E9941268 /* Resources */ = { isa = PBXGroup; @@ -171,6 +222,7 @@ 674BB9709E48A6D00D4EE92C /* AlarmSoundPlayer.swift */, C1371DB889435F70346FB6AF /* CompanionQuotaModel.swift */, 1C1CF45BE162CC9B8CD968DC /* CompanionTheme.swift */, + A736FD344F79D520A16227B3 /* RemotePushRegistration.swift */, 89A5807594DFD989CADCC6F2 /* ResetAlarmSound.swift */, E124B68EB2FF725D59E2EC1F /* SentenceGap.swift */, ); @@ -187,6 +239,8 @@ 34CE8BC7DF9DCF088BF1B805 /* CodeCapsCompanionApp.swift */, 154D17A5A492536F43729222 /* Info.plist */, C2EA171A442F77D9348C68D4 /* InfoMac.plist */, + 13B67B47EAC3ED255FDB0018 /* PrivacyInfo.xcprivacy */, + 50C98744F4C98CE0C6227834 /* SentryTelemetry.swift */, ); path = App; sourceTree = ""; @@ -203,9 +257,14 @@ isa = PBXGroup; children = ( D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */, + D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */, + 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */, + 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */, 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */, 5ECFD3E9DC2728ACB5FB5488 /* ResetAlarmMessage.swift */, D5FD1EFF75431229A669FC5A /* ResetAlarmTracker.swift */, + D9E8877EA13789E3986BFEF9 /* UsageAlertPreferences.swift */, + 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */, 9DE3A7D9C36FF286A082F60E /* WidgetSnapshotFetcher.swift */, ); name = Shared; @@ -258,6 +317,7 @@ buildPhases = ( 5D95C7E939C75DD1BC3FB9A4 /* Sources */, F6BBA5E1D41955121FAB7BDB /* Resources */, + 1D9F2C453D32145A481FD28E /* Frameworks */, 3101B2E13CD6120DF91E6796 /* Embed Foundation Extensions */, ); buildRules = ( @@ -267,6 +327,7 @@ ); name = CodeCapsCompanion; packageProductDependencies = ( + 07191D727884C7621506D926 /* Sentry */, ); productName = CodeCapsCompanion; productReference = FFD1407F089F595B7B485517 /* CodeCapsCompanion.app */; @@ -296,6 +357,7 @@ buildPhases = ( 05C6704BA23A5E8CD1A8412F /* Sources */, 30E6F3D60263C25E6403C9E4 /* Resources */, + F9880B8D20C15C76764708DB /* Frameworks */, A5CDFCA78778D754F2C17BC0 /* Embed Foundation Extensions */, ); buildRules = ( @@ -305,6 +367,7 @@ ); name = CodeCapsCompanionMac; packageProductDependencies = ( + 897AB651A949F8B5454F5C3C /* Sentry */, ); productName = CodeCapsCompanionMac; productReference = C679EF2BE96C94E938E56DC1 /* CodeCapsCompanionMac.app */; @@ -342,9 +405,11 @@ }; 27F7C2D31B71471EDEB75F09 = { DevelopmentTeam = CC8UTF7ATG; + ProvisioningStyle = Automatic; }; 7B3804F93936EEE96F26551D = { DevelopmentTeam = CC8UTF7ATG; + ProvisioningStyle = Automatic; }; B8CBE2BF110881FA5BE5725B = { DevelopmentTeam = CC8UTF7ATG; @@ -360,6 +425,9 @@ ); mainGroup = 8E8ACB61F08661AAACE99C81; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 53D4D087439DA6FAE23B49F4 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + ); preferredProjectObjectVersion = 77; productRefGroup = C53A412A4B40988009B93D1B /* Products */; projectDirPath = ""; @@ -387,6 +455,7 @@ buildActionMask = 2147483647; files = ( 034780ECDDEB89E523E7E1B7 /* Assets.xcassets in Resources */, + 1FFA2ED8A54A8F8FDE6C2226 /* PrivacyInfo.xcprivacy in Resources */, 1F27C36495D3FB241A242476 /* alarm-blow.wav in Resources */, AFBBFB2FD8EEA9DA9EF40042 /* alarm-bottle.wav in Resources */, E77E1CAAEDCB9C3AAD706144 /* alarm-frog.wav in Resources */, @@ -410,6 +479,7 @@ buildActionMask = 2147483647; files = ( 2300043111EDF02526E8D53B /* Assets.xcassets in Resources */, + E38B758D8183E576DEE41A6C /* PrivacyInfo.xcprivacy in Resources */, C23393B11365D16E8ECA5D8E /* alarm-blow.wav in Resources */, BD4C31C68641BA2ED6BE8798 /* alarm-bottle.wav in Resources */, E4F68F451FCE5A7069DA052D /* alarm-frog.wav in Resources */, @@ -433,11 +503,17 @@ 0BDDE676F287EA3372B9D24B /* CompanionQuotaModel.swift in Sources */, EA216B5CF6C52764BFD21988 /* CompanionReadTokenStore.swift in Sources */, F245CD4AA6FA8D9C23A5D373 /* CompanionTheme.swift in Sources */, + 500162E6C6D0CFA94D715BD0 /* FleetSourceDisagreement.swift in Sources */, + A82BDBD544318CBB2DC1FE96 /* QuotaWindowVocabulary.swift in Sources */, + 9DCBAAF5AED382505D06C03D /* RemotePushRegistration.swift in Sources */, 1D4D70BAAB091F8992DB1693 /* ResetAlarmCadence.swift in Sources */, 99E4B46415A2BCB252FAA6C0 /* ResetAlarmMessage.swift in Sources */, 831B4D460D912E65E3D869D3 /* ResetAlarmSound.swift in Sources */, E0EE20CDC3987E143A3FCB48 /* ResetAlarmTracker.swift in Sources */, 135997B0E383FB2D00C87CB0 /* SentenceGap.swift in Sources */, + 41F6AE401AE48C11EDDA4B80 /* SentryTelemetry.swift in Sources */, + CADE19D9C4D7A21C22D894AC /* UsageAlertPreferences.swift in Sources */, + BE0C14400B9E798CA08168F7 /* WidgetPlatformDisplay.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,11 +527,17 @@ C48D9F8052E4F39044504C84 /* CompanionQuotaModel.swift in Sources */, 4F76A47C0A1C84A3525F004C /* CompanionReadTokenStore.swift in Sources */, 311AB846015F2FEDF0014C43 /* CompanionTheme.swift in Sources */, + 67E08982F84A1E831F95464B /* FleetSourceDisagreement.swift in Sources */, + 7DA53137FC1697E13889298B /* QuotaWindowVocabulary.swift in Sources */, + 55B1F0DECD506D0BACFFBBB7 /* RemotePushRegistration.swift in Sources */, 31CFC3813E00700F4A1D0E2F /* ResetAlarmCadence.swift in Sources */, DA221384FC017A40031C0446 /* ResetAlarmMessage.swift in Sources */, 9D826DF8BA1D0C240F17EAFC /* ResetAlarmSound.swift in Sources */, BF0A24BA7D7B70B182950DA9 /* ResetAlarmTracker.swift in Sources */, 137C79F45750F4FA04219AFE /* SentenceGap.swift in Sources */, + D88D34F03E978D88086FFCAE /* SentryTelemetry.swift in Sources */, + 6BF5A6C33EC9F93F7354379F /* UsageAlertPreferences.swift in Sources */, + 33D335245DED8229722048BA /* WidgetPlatformDisplay.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -465,9 +547,13 @@ files = ( 170FA7844615930B5A1D4CBD /* CodeCapsWidgetBundle.swift in Sources */, 6AF2CF64E8C9F39D87C4B24C /* CompanionReadTokenStore.swift in Sources */, + F4FCB18D25C83B7AAD11C565 /* FleetSourceDisagreement.swift in Sources */, + 3AB7A6CDA67C38224B7CBE08 /* QuotaWindowCaption.swift in Sources */, + 28EECA14D0CD8CE69B1250B6 /* QuotaWindowVocabulary.swift in Sources */, 3C07D53C4EFEE8F91EEDDEC7 /* ResetAlarmCadence.swift in Sources */, 0FB40BD33AB5375F6C02ACFC /* WidgetConfigurationIntent.swift in Sources */, AB8C667473DF14CCF7B48F2A /* WidgetDataProvider.swift in Sources */, + D403E6C85AA58126F1F15557 /* WidgetPlatformDisplay.swift in Sources */, C7498C0ACDD5320564DE32B0 /* WidgetPresentation.swift in Sources */, C1B3C6C3651687B8283C75CD /* WidgetSnapshotFetcher.swift in Sources */, 1C938ECE1998071D65A908EA /* WidgetViews.swift in Sources */, @@ -481,9 +567,13 @@ files = ( EA984A6E75520CAB2639A8B4 /* CodeCapsWidgetBundle.swift in Sources */, E8DE0A145EA5D76CD51886E8 /* CompanionReadTokenStore.swift in Sources */, + 80AFBDA4491ADD0F917DEF11 /* FleetSourceDisagreement.swift in Sources */, + 4BBB6BFF85C4F547B32A3035 /* QuotaWindowCaption.swift in Sources */, + 8A473847ACFCA158CD1E9386 /* QuotaWindowVocabulary.swift in Sources */, BBE95186FD35EC37DBF13D51 /* ResetAlarmCadence.swift in Sources */, A90E0EF383B2CDF27263BAF0 /* WidgetConfigurationIntent.swift in Sources */, D3C30EA2E4A6B59A22E086DB /* WidgetDataProvider.swift in Sources */, + A27D61CE3DC2A52A0B83E662 /* WidgetPlatformDisplay.swift in Sources */, F3C6891672DADC0AD9667E90 /* WidgetPresentation.swift in Sources */, 689104F1DF3DDC7C7F75D168 /* WidgetSnapshotFetcher.swift in Sources */, 2AA3DE1AA8DD20DD5AD6E403 /* WidgetViews.swift in Sources */, @@ -534,8 +624,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Generated/CodeCapsCompanionMac.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = CC8UTF7ATG; GENERATE_INFOPLIST_FILE = NO; @@ -548,7 +637,6 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.simplewithus.codecaps.macos; PRODUCT_NAME = CodeCaps; - PROVISIONING_PROFILE_SPECIFIER = "CodeCaps for Mac AppStore"; SDKROOT = macosx; }; name = Release; @@ -557,8 +645,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = Generated/CodeCapsWidgetsMac.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = CC8UTF7ATG; GENERATE_INFOPLIST_FILE = NO; @@ -572,7 +659,6 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.simplewithus.codecaps.macos.widgets; PRODUCT_NAME = CodeCapsWidgets; - PROVISIONING_PROFILE_SPECIFIER = "CodeCaps Mac Widgets AppStore"; SDKROOT = macosx; }; name = Release; @@ -654,6 +740,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + SENTRY_DSN = ""; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = targeted; @@ -743,6 +830,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + SENTRY_DSN = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = targeted; @@ -776,6 +864,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODECAPS_APNS_ENVIRONMENT = development; CODE_SIGN_ENTITLEMENTS = App/CodeCapsCompanion.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; @@ -797,6 +886,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODECAPS_APNS_ENVIRONMENT = production; CODE_SIGN_ENTITLEMENTS = App/CodeCapsCompanion.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; @@ -864,6 +954,30 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 53D4D087439DA6FAE23B49F4 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/getsentry/sentry-cocoa.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.30.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 07191D727884C7621506D926 /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = 53D4D087439DA6FAE23B49F4 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; + 897AB651A949F8B5454F5C3C /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = 53D4D087439DA6FAE23B49F4 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 6952667DEF0FAD0771A6E8A9 /* Project object */; } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index 66e9593c..27797801 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -84,6 +84,19 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { WidgetPresentation.shortCadence(cadence: cadence, label: label) } + /// The caption this window's bar prints. + /// + /// The caption carries the qualifier, so a surface that renders it cannot leave + /// the qualifier out: a caption built from the window and a caption built + /// without it are different values, and only the first one names the sibling. + public var caption: QuotaWindowCaption { + QuotaWindowCaption( + token: cadenceToken, + qualifier: barQualifier, + resetCaption: resetCaption() + ) + } + /// A copy of this window that prints the given qualifier in its caption /// row. The platform item calls this once it knows the sibling windows. func withBarQualifier(_ qualifier: String) -> WidgetWindowItem { diff --git a/ios/CodeCapsCompanion/Widgets/WidgetViews.swift b/ios/CodeCapsCompanion/Widgets/WidgetViews.swift index 5e0f1a94..5aa8547e 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetViews.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetViews.swift @@ -547,7 +547,10 @@ struct ProviderFocusView: View { .lineLimit(2) .minimumScaleFactor(0.85) } else { - WindowCaptionRow(token: captionToken, resetCaption: captionReset, font: 9) + WindowCaptionRow( + caption: .withoutQualifier(token: captionToken, resetCaption: captionReset), + font: 9 + ) } } .padding(12) @@ -640,7 +643,10 @@ struct ProviderFocusView: View { percentFont: 11 ) } else if windowsToShow.isEmpty { - WindowCaptionRow(token: captionToken, resetCaption: captionReset, font: 9.5) + WindowCaptionRow( + caption: .withoutQualifier(token: captionToken, resetCaption: captionReset), + font: 9.5 + ) } else { ForEach(windowsToShow) { win in VStack(alignment: .leading, spacing: 2) { @@ -660,8 +666,7 @@ struct ProviderFocusView: View { height: 3.5 ) WindowCaptionRow( - token: win.cadenceToken, - resetCaption: win.resetCaption(), + caption: win.caption, font: 9 ) } @@ -717,8 +722,7 @@ struct AccessoryView: View { .font(.system(size: 12, weight: .bold, design: .rounded)) } WindowCaptionRow( - token: atCap.cadenceToken, - resetCaption: atCap.resetCaption(), + caption: atCap.caption, font: 9 ) } @@ -772,14 +776,12 @@ struct AccessoryView: View { } HStack(spacing: 4) { WindowCaptionRow( - token: pair.window1.cadenceToken, - resetCaption: pair.window1.resetCaption(), + caption: pair.window1.caption, font: 8.5 ) .frame(maxWidth: .infinity) WindowCaptionRow( - token: pair.window2.cadenceToken, - resetCaption: pair.window2.resetCaption(), + caption: pair.window2.caption, font: 8.5 ) .frame(maxWidth: .infinity) @@ -798,9 +800,14 @@ struct AccessoryView: View { } MiniProgressBar(fraction: platform.progressFraction, color: .primary, elapsedFraction: platform.elapsedFraction, height: 4) WindowCaptionRow( - token: platform.controllingWindow(entry.windowPick)?.cadenceToken - ?? WidgetPresentation.shortCadence(cadence: platform.subtitle, label: platform.title), - resetCaption: platform.controllingWindow(entry.windowPick)?.resetCaption() ?? platform.resetCaption(), + caption: platform.controllingWindow(entry.windowPick)?.caption + ?? .withoutQualifier( + token: WidgetPresentation.shortCadence( + cadence: platform.subtitle, + label: platform.title + ), + resetCaption: platform.resetCaption() + ), font: 9 ) } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift b/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift index 8c92e3a5..7de37b60 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift @@ -61,26 +61,28 @@ struct CodeCapsMarkView: View { /// 3h 12m". Both halves are optional โ€” a window with no reset shows the token /// alone, and one with an unknown cadence shows the reset alone. struct WindowCaptionRow: View { - let token: String - let resetCaption: String + /// What the caption says. + /// + /// It is a value and not three arguments, because the qualifier used to be an + /// argument with a default: a surface that rendered a window's caption could + /// leave it out, and the omission showed up only in a review. A caption now + /// carries its qualifier, and one built from a window carries that window's. + let caption: QuotaWindowCaption var font: CGFloat = 9 - /// Short distinguishing label shown after the token when two bars share - /// one cadence (e.g. "weekly" / "additional" for two "7d" bars). - var qualifier: String = "" var body: some View { HStack(spacing: 4) { - if !token.isEmpty || !qualifier.isEmpty { + if !caption.token.isEmpty || !caption.qualifier.isEmpty { HStack(spacing: 3) { - if !token.isEmpty { - Text(token) + if !caption.token.isEmpty { + Text(caption.token) .font(.system(size: font, weight: .semibold, design: .rounded)) .foregroundColor(.secondary) .lineLimit(1) .fixedSize() } - if !qualifier.isEmpty { - Text(qualifier) + if !caption.qualifier.isEmpty { + Text(caption.qualifier) .font(.system(size: font, weight: .medium)) .foregroundColor(.secondary.opacity(0.8)) .lineLimit(1) @@ -89,8 +91,8 @@ struct WindowCaptionRow: View { } } Spacer(minLength: 0) - if !resetCaption.isEmpty { - Text(resetCaption) + if !caption.resetCaption.isEmpty { + Text(caption.resetCaption) .font(.system(size: font, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) @@ -147,10 +149,8 @@ struct PlanAtCapRow: View { } WindowCaptionRow( - token: captionToken, - resetCaption: window.resetCaption(), - font: captionFont, - qualifier: window.barQualifier + caption: window.caption, + font: captionFont ) } .accessibilityElement(children: .combine) @@ -258,8 +258,7 @@ struct PlanBarRow: View { ) WindowCaptionRow( - token: captionToken, - resetCaption: captionReset, + caption: .withoutQualifier(token: captionToken, resetCaption: captionReset), font: captionFont ) } @@ -352,18 +351,14 @@ struct PlanDualBarRow: View { // Dual captions HStack(spacing: columnSpacing) { WindowCaptionRow( - token: window1.cadenceToken, - resetCaption: window1.resetCaption(), - font: captionFont, - qualifier: window1.barQualifier + caption: window1.caption, + font: captionFont ) .frame(maxWidth: .infinity) WindowCaptionRow( - token: window2.cadenceToken, - resetCaption: window2.resetCaption(), - font: captionFont, - qualifier: window2.barQualifier + caption: window2.caption, + font: captionFont ) .frame(maxWidth: .infinity) } diff --git a/ios/CodeCapsCompanion/project.yml b/ios/CodeCapsCompanion/project.yml index acb49831..5ef23395 100644 --- a/ios/CodeCapsCompanion/project.yml +++ b/ios/CodeCapsCompanion/project.yml @@ -111,6 +111,8 @@ targets: group: Shared - path: ../../Sources/QuotaCore/QuotaWindowVocabulary.swift group: Shared + - path: ../../Sources/QuotaCore/QuotaWindowCaption.swift + group: Shared - path: ../../Sources/QuotaCore/FleetSourceDisagreement.swift group: Shared - path: ../../Sources/QuotaCore/WidgetPlatformDisplay.swift @@ -209,6 +211,8 @@ targets: group: Shared - path: ../../Sources/QuotaCore/QuotaWindowVocabulary.swift group: Shared + - path: ../../Sources/QuotaCore/QuotaWindowCaption.swift + group: Shared - path: ../../Sources/QuotaCore/FleetSourceDisagreement.swift group: Shared - path: ../../Sources/QuotaCore/WidgetPlatformDisplay.swift From ebf99bab3be2bb38b6256a25a56be5cee4736afa Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:32:59 -0500 Subject: [PATCH 48/50] Ship when the shared caption changes A shared file the iOS targets compile has to be a push path of the ship workflow and a prefix of the scheduled gate, or a change to it reaches the App Store build without a build. The caption file is compiled by the widget targets, so it is in both lists now. --- .github/workflows/ios-ship.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index ec53164e..8a213e8a 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -64,6 +64,7 @@ on: # ship a TestFlight build. - 'Sources/QuotaCore/CompanionReadTokenStore.swift' - 'Sources/QuotaCore/FleetSourceDisagreement.swift' + - 'Sources/QuotaCore/QuotaWindowCaption.swift' - 'Sources/QuotaCore/QuotaWindowVocabulary.swift' - 'Sources/QuotaCore/ResetAlarmCadence.swift' - 'Sources/QuotaCore/ResetAlarmMessage.swift' @@ -130,6 +131,7 @@ jobs: --path-prefix 'ios/CodeCapsCompanion/' \ --path-prefix 'Sources/QuotaCore/CompanionReadTokenStore.swift' \ --path-prefix 'Sources/QuotaCore/FleetSourceDisagreement.swift' \ + --path-prefix 'Sources/QuotaCore/QuotaWindowCaption.swift' \ --path-prefix 'Sources/QuotaCore/QuotaWindowVocabulary.swift' \ --path-prefix 'Sources/QuotaCore/ResetAlarmCadence.swift' \ --path-prefix 'Sources/QuotaCore/ResetAlarmMessage.swift' \ From 9b472d254168c89639b0c9ab3ff3e99e6480e4f0 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:39:24 -0500 Subject: [PATCH 49/50] Read a process's environment the way the runner can The contract job runs on Linux, and the test that proves the key never reaches a command line read the environment with macOS's ps. Linux's ps has no such flag, so the probe failed with "unsupported SysV option" and the check that matters never ran. The probe now reads the environment the way the platform offers it: macOS's ps with -E, and Linux's /proc//environ, which the owner of the process can read. All fourteen tests pass in a Linux container, which is the environment the job runs in. --- scripts/ios-fleet/test-asc-key.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs index a68d7d7e..38812e55 100644 --- a/scripts/ios-fleet/test-asc-key.test.mjs +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -272,7 +272,14 @@ function processArgs(pid) { // The environment of one process, the way another process of the same user // can read it on this platform. function processEnvironment(pid) { - return execFileSync("ps", ["-E", "-p", String(pid), "-o", "args="], { encoding: "utf8" }); + if (process.platform === "darwin") { + // macOS's ps prints the environment with -E. + return execFileSync("ps", ["-E", "-p", String(pid), "-o", "args="], { encoding: "utf8" }); + } + // Linux's ps has no such flag and keeps the environment in a file the owner + // of the process can read. The contract job runs on Linux, so the probe + // has to work there too. + return readFileSync(`/proc/${pid}/environ`, "utf8").replace(/\0/g, "\n"); } // The children of one process. From e36c57e422f8cac9552bea328e5c904539f6911e Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:42:16 -0500 Subject: [PATCH 50/50] Prove the two group rules the review found The extra allowance joining the group of the one sibling that has a period, and the canonical labels naming their period, were fixed but unproved. Both rules now have a test that fails without the fix. --- .../QuotaWindowVocabularyTests.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift b/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift index 083b5ffd..b796a94b 100644 --- a/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift +++ b/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift @@ -8,6 +8,47 @@ final class QuotaWindowVocabularyTests: XCTestCase { XCTAssertEqual(QuotaWindowVocabulary.standardCadence(windowToken: "1m", label: ""), "1-month") } + /// An extra allowance names no period of its own. + /// + /// It joins the group of the one sibling that has a period, and the sibling + /// resolution is what does that. The group lookup itself must not hand it a + /// period, or a bar that prints "7d" would be split from the sibling that + /// prints the same token (Kody review 2026-10-21). + func testAnExtraAllowanceHasNoGroupOfItsOwn() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup( + cadence: "Other limit", + label: "Other limit", + isExtraAllowance: true + ), + QuotaWindowVocabulary.otherLimit + ) + } + + /// A canonical label names its period. + /// + /// The widget renders "7-day window", "5-hour window" and "1-month window", + /// and a bar whose label says one of them has to group with the bar that + /// prints the same token, or the two are split (Kody review 2026-10-21). + func testACanonicalLabelNamesItsPeriod() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "7-day window"), + QuotaWindowVocabulary.sevenDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "5-hour window"), + QuotaWindowVocabulary.fiveHour + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "1-month window"), + QuotaWindowVocabulary.oneMonth + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "1-day window"), + QuotaWindowVocabulary.oneDay + ) + } + func testOpaqueAnthropicKeyMapsToOtherLimit() { let label = QuotaWindowVocabulary.windowLabel(windowToken: nil, existingLabel: "iguana_necktie", resetAt: nil, periodStart: nil) XCTAssertEqual(label, "Other limit window")