Skip to content

CC-169: capture iOS App Store screenshots on a runner and upload them to App Store Connect - #271

Open
jaywedgeworth22 wants to merge 22 commits into
mainfrom
minimax/ios-appstore-screenshots
Open

jaywedgeworth22 wants to merge 22 commits into
mainfrom
minimax/ios-appstore-screenshots

Conversation

@jaywedgeworth22

@jaywedgeworth22 jaywedgeworth22 commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

What this does

Captures the six App Store screenshots for the iOS companion (3 screens × iPhone and iPad) on a macOS runner.  It uploads nothing on its own: the upload stays an explicit local step, one display set per call.  It also fixes the source-scope label, which read "This iPhone" on an iPad.

Why the capture measures the frame

The companion takes 9-18s to render on a cold simulator, and a fixed sleep captured a blank window more than once.  screenshot_ink.py measures how much of a frame is not background, and the capture keeps a frame only once the screen has actually drawn.  It reports two numbers: the share of the whole frame that is ink, 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 second number is what separates a finished screen from a half-drawn one.

The three screens

Fleet Status, Platform Order and Alarms.  settings went as a near-duplicate of Platform Order; sources-refresh went because it shows a red Keychain error banner.  Alarms is reached with a new -openAlarms launch argument rather than with a near-duplicate screenshot.

Frames are captured as JPEG from simctl and converted to PNG with sips, because App Store Connect rejects a screenshot that carries an alpha channel.

Review follow-ups

Two rounds of review findings are addressed.  The first round added the request timeout, the reservation cleanup on every upload failure path, the sanitised error output, the hand-written shape guards, the per-screen ink minimums, the single build for both simulators, and the narrower workflow trigger.  The second round made a transport failure a transport failure instead of a server verdict, accepted the production host explicitly, stopped the failure paths from losing their own message, and rebuilt the render gate so that holding still is a second condition on the thresholds rather than a way around them.  scripts/ios-fleet/test-asc-upload.mjs and scripts/ios-fleet/test-asc-client.mjs are new and drive the real client against a local server.

Let me analyze the code changes to understand what was implemented in this pull request.

The PR is titled "CC-169: capture iOS App Store screenshots on a runner and upload them to App Store Connect"

Looking at the changes:

  1. New file: .github/workflows/ios-appstore-screenshots.yml

    • Creates a GitHub Actions workflow named "iOS App Store Screenshots"
    • Triggers on workflow_dispatch or push to main (when related files change)
    • Runs on macos-latest with 60 min timeout
    • Steps:
      • Checkout code
      • Install XcodeGen if not present
      • Run capture script
      • Report captured sizes via sips
      • Upload screenshots as artifact
    • Notes explicitly that upload to App Store Connect is NOT done here (to prevent duplicate uploads on re-runs)
  2. Modified file: scripts/ios-fleet/asc-api.mjs

    • Added usage line for new command upload-screenshots
    • Added new functionality for uploading App Store screenshots
    • Implements a three-step process:
      1. POST to /v1/appScreenshots to reserve
      2. PUT to upload bytes
      3. PATCH /v1/appScreenshots/<id> to commit
    • Finds or creates the screenshot set for a given display type
    • Validates the asset delivery state after upload
  3. New file: scripts/ios-fleet/capture-appstore-screenshots.sh

    • Bash script to capture App Store screenshots
    • Builds companion app for iPhone (6.9") and iPad (13") simulators
    • Seeds quota snapshot data into the simulator's app container
    • Captures three screens per device: fleet-status, settings, platform-order
    • Asserts screenshot dimensions match App Store Connect requirements
    • Handles alpha channel by converting PNG to JPEG if needed

Now looking at the review findings:

  • High: The app isn't launched before the first screenshot capture (just simctl io screenshot), so the first "fleet-status" screenshot is actually the home screen, not the app
  • Medium: cancel-in-progress cancels in-progress pushes to main (should queue instead)
  • Medium: Preferred simulator lists include devices with wrong native pixel dimensions
  • Medium: Task description insufficient for business logic validation

Let me write a concise description focused on what was implemented.

This PR introduces automated capture of iOS App Store screenshots via a GitHub Actions workflow, with accompanying tooling for uploading them to App Store Connect.

What's implemented:

  1. New GitHub Actions workflow (.github/workflows/ios-appstore-screenshots.yml) — Runs on macos-latest, installs XcodeGen, executes the capture script on a runner that has iOS simulator runtimes (since the developer's local Mac does not), reports the captured pixel dimensions, and uploads the resulting screenshots as workflow artifacts. Triggers on manual dispatch or pushes to main that touch the companion app, capture script, or workflow file.

  2. New capture script (scripts/ios-fleet/capture-appstore-screenshots.sh) — Builds the CodeCaps companion for one iPhone (6.9") and one iPad (13") simulator selected from a preferred-device list, installs the app, seeds each simulator's app container with a realistic quota snapshot JSON (the format the companion reads as a local fallback), and captures three screens per device: the quota list ("fleet-status"), the settings sheet, and the platform-order editor (the latter two opened via the companion's own launch arguments). Each capture is asserted against the exact pixel dimensions App Store Connect accepts (1320×2868 for iPhone, 2064×2752 for iPad) and flattened to JPEG if the PNG carries an alpha channel.

  3. New App Store Connect upload sub-command in scripts/ios-fleet/asc-api.mjs — Adds upload-screenshots <localizationId> <displayType> <file...> which finds or creates the screenshot set for a given display type on the localization, then runs the three-step upload flow for each file: reserve the asset with POST /v1/appScreenshots, upload the bytes via Apple's pre-signed PUT URLs, and commit with PATCH /v1/appScreenshots/<id> to mark uploaded=true and surface any assetDeliveryState errors.

Key design choice: The workflow captures and uploads screenshots as an artifact but deliberately does NOT push them to App Store Connect. The upload is a separate, explicit step run on a machine holding the App Store Connect key, so a re-run of this workflow cannot silently add duplicate screenshots to a live version.


The user wants me to generate a pull request description based on the code changes. Let me analyze the patch:

The file is scripts/ios-fleet/capture-appstore-screenshots.sh but looking at the patch, it actually shows Python-looking content (def reset_in, providers list, etc.). This seems to be a configuration update for provider windows/cadences.

Changes:

  1. Updated the percentage value for "anthropic-7d" from 44.0 to 78.0
  2. Replaced "cursor-fast" and "cursor-total" entries with a single "cursor-monthly" entry using a "1m" cadence and 34.0 percent
  3. Updated "minimax" provider label from "minimax" to "MiniMax"
  4. Removed "antigravity-daily" entry
  5. Added a comment explaining the window tokens and the rationale for excluding Antigravity

The PR title mentions "capture iOS App Store screenshots on a runner and upload them to App Store Connect" but the actual code changes seem to be about provider configuration for some kind of dashboard/widget that shows usage quotas.

Looking at the context more carefully, this appears to be about provider window configuration for a dashboard widget that displays usage quotas across different AI providers (Anthropic, OpenAI, Cursor, xAI, etc.). The changes:

  1. Anthropic 7-day window: Updated the displayed percentage value (44.0 → 78.0)
  2. Cursor provider: Consolidated "Fast Requests window" and "Total Requests window" entries into a single "1-month window" entry
  3. MiniMax provider label: Renamed from "minimax" to "MiniMax"
  4. Antigravity: Removed the entry intentionally because its windows are pooled into per-model sections and would not read as a single platform card
  5. Comment added: Explains the window tokens and rules

Wait, the PR title says "CC-169: capture iOS App Store screenshots on a runner and upload them to App Store Connect" but the code changes are about provider windows. This seems inconsistent. However, my job is to describe what the code changes do, not the PR title.

Let me write a description focused on what the code changes actually do.

The file path is scripts/ios-fleet/capture-appstore-screenshots.sh but the content looks like a Python script for a dashboard widget configuration. The changes are:

  • Adjusted the displayed percentage for the Anthropic 7-day window
  • Replaced two Cursor window entries (Fast Requests, Total Requests) with a single 1-month window entry
  • Renamed the MiniMax provider label to "MiniMax" (proper capitalization)
  • Removed the Antigravity provider entry
  • Added explanatory comments about window token vocabulary and the rationale for excluding Antigravity

I need to provide a concise description. Let me focus on the functional impact.

Update the provider window list used by the dashboard widget:

  • Anthropic 7-day window: Adjusted the displayed percentage value.
  • Cursor: Replaced the separate Fast Requests and Total Requests window entries with a single 1-month window entry, keeping only one row per provider.
  • MiniMax: Renamed the provider label to use proper capitalization ("MiniMax").
  • Antigravity: Removed the entry, since its windows are pooled into per-model sections and would not render as a single platform card.
  • Added a comment explaining the supported window tokens (5h, 7d, 1d, 1m), the rule that a second window of the same provider must use a different cadence, and the rationale for excluding Antigravity.

The user wants me to generate a precise PR description based on the code changes shown. Let me analyze each file:

  1. .github/workflows/ios-appstore-screenshots.yml:

    • Updated the example command in the comment to use auto mode and pass both iPhone and iPad PNGs
    • Changed the concurrency cancel-in-progress to only apply on pull_request events, not on push to main (to avoid reporting cancelled checks for a commit that nothing replaced)
  2. scripts/ios-fleet/asc-api.mjs:

    • Added support for auto display type that reads PNG dimensions from the first file and maps them to the appropriate App Store Connect display type
    • Added a sizeToDisplayType mapping for various iPhone and iPad sizes
    • When auto is used, it reads the PNG header to get dimensions and picks the corresponding display type
    • This prevents mis-specifying the display type
  3. scripts/ios-fleet/capture-appstore-screenshots.sh:

    • Removed the BUNDLE_ID hardcoded value
    • Reorganized the comment about accepted display sizes to include more sizes (iPhone 6.5", iPad 12.9" 2nd gen)
    • Added a png_size() helper to read PNG dimensions
    • Added a display_class_for_size() helper to map pixel sizes to display classes (iphone/ipad)
    • Changed log() to write to stderr
    • Changed assert_screenshot() to verify against the captured size rather than hardcoded dimensions
    • Refactored capture_on() to not assume specific dimensions - it captures, then measures, then asserts
    • The output files are renamed based on the actual display class (iphone or ipad) rather than the input label
    • Added validation that the booted simulator family matches the captured pixel size

The review findings indicate:

  • High: The auto mode only reads PNG dimensions from files[0], which could cause iPad PNGs to be misclassified if the iPhone file comes first
  • Medium: The iPad Pro 12.9" 2nd generation comment claiming 2000x2664 is wrong - the real size is 2048x2732

Let me write a concise PR description based on this.

This PR implements automated capture and upload of iOS App Store screenshots via a GitHub Actions runner (CC-169).

Workflow changes (.github/workflows/ios-appstore-screenshots.yml):

  • Updated the documented one-line upload invocation to use auto display-type detection and pass both iPhone and iPad PNGs together.
  • Restricted concurrency cancellation to pull_request runs only, so a push to main no longer reports cancelled runs for an unsuperseded commit.

Upload script changes (scripts/ios-fleet/asc-api.mjs):

  • Added an auto mode for upload-screenshots that reads PNG dimensions from the first file's header and routes it to the correct App Store Connect screenshot set (e.g., APP_IPHONE_67, APP_IPAD_PRO_129) via a pixel-size-to-display-type lookup table.
  • Documents that App Store Connect has no APP_IPHONE_69 or APP_IPAD_PRO_13, so 6.9" iPhone sizes map to APP_IPHONE_67 and 13" iPad sizes map to APP_IPAD_PRO_129.

Capture script changes (scripts/ios-fleet/capture-appstore-screenshots.sh):

  • Expanded the supported device list to cover more accepted sizes, including the older iPhone 6.5" and iPad 12.9" sizes (1284x2778, 1242x2688, 2048x2732, 2000x2664).
  • Replaced hardcoded width/height assertions with a measurement-driven approach: after capture, the script reads the PNG's actual pixel size, validates that size against App Store Connect's accepted display classes, and asserts the booted simulator family matches the captured size.
  • Output files are now renamed based on the measured display class (iphone-* or ipad-*) instead of the input label, so the upload step receives correctly classified files regardless of which simulator actually rendered them.
  • Generalized the capture loop to relaunch the app before each of the three screens (fleet-status, settings, platform-order) and fixed a logging bug (log() now writes to stderr to avoid contaminating captured file paths).

The user wants me to generate a precise PR description based on the code changes. Let me analyze the patch carefully.

The changes are in scripts/ios-fleet/asc-api.mjs, specifically in the screenshot upload functionality. Let me understand what changed:

  1. Before: The code only checked the first file's dimensions to determine the display type. It only validated the existence of files[0] and read its header to determine size and display type. This meant if you passed a mixed list of iPhone and iPad screenshots, it would only check the first file and use that display type for all uploads.

  2. After: The code now:

    • Iterates through ALL files (not just the first one) to validate existence and read dimensions
    • Stores each file's size and display type in a map
    • If displayType === "auto", it uses the display type of the first file
    • Then validates that ALL files belong to the same display type as the chosen one
    • If any file's display type doesn't match, it errors out with a clear message indicating the file's actual size and the expected display type, instructing the user to run those files separately

The comment in the code explains the rationale: "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."

So the functional purpose is:

  • Previously, uploading a mixed list of iPhone and iPad screenshots could incorrectly send iPad files to an iPhone screenshot set (or vice versa), because only the first file was inspected
  • Now, all files are validated to ensure they all belong to the same display type/screenshot set
  • If a mixed list is provided, the script now rejects it with a clear error message instead of silently uploading to the wrong set

Let me write a concise PR description.

CC-169: capture iOS App Store screenshots on a runner and upload them to App Store Connect

Summary

Hardened the upload-screenshots command in scripts/ios-fleet/asc-api.mjs so it rejects mixed-device screenshot lists instead of silently uploading them to the wrong App Store Connect set.

What changed

  • All input files are now inspected (not just the first one): each file's existence is verified and its PNG dimensions are read into a per-file map of size → display type.
  • When displayType is auto, it is derived from the first file as before.
  • A new validation pass confirms that every file resolves to the same display type as the chosen one. If a file belongs to a different family (e.g., an iPad screenshot in an iPhone run, or vice versa), the command exits with an error that names the offending file, its actual size, its correct display type, and instructs the operator to run that family on its own.

Why

A capture run produces iPhone and iPad screenshots together, but App Store Connect requires them to be uploaded to separate screenshot sets. The previous logic only checked files[0], so a mixed list would send later-family files to the first family's set. The script now refuses the mixed list up front, preventing misrouted uploads.


Let me analyze the code changes in this pull request to understand what was implemented.

The PR title is: "CC-169: capture iOS App Store screenshots on a runner and upload them to App Store Connect"

Key files added/modified:

  1. .github/workflows/ios-appstore-screenshots.yml (added) - GitHub Actions workflow that:

    • Triggers on workflow_dispatch, push to main (when related files change)
    • Runs on macOS-latest
    • Installs XcodeGen
    • Calls the capture script
    • Reports captured sizes
    • Uploads screenshots as artifacts (does NOT upload to App Store Connect)
  2. .github/workflows/ios-ship-contract.yml (modified) - Added test runs for the new Python scripts

  3. .gitignore (modified) - Added Python cache files

  4. Tests/CodeCapsTests/CompanionModelTests.swift (modified) - Added tests for:

    • Source scope label naming the device family (iPhone/iPad/Mac)
    • Launch argument -openAlarms to open the alarms tab
  5. ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift (modified) - Added:

    • displayTitle property that returns the device-aware label (e.g., "This iPad" on iPad)
    • currentDeviceFamily static property that returns "iPhone", "iPad", or "Mac"
    • title(for family:) method for testing
  6. ios/CodeCapsCompanion/App/Views/CompanionContentView.swift (modified) - Changed:

    • Source scope picker to use displayTitle instead of rawValue
    • Added -openAlarms launch argument support to open the alarms tab
    • Settings tab is now selected based on launch arguments
  7. scripts/ios-fleet/asc-api.mjs (modified) - Added:

    • upload-screenshots command that:
      • Reserves app screenshots
      • PUTs the bytes to Apple's pre-signed URL
      • Commits the screenshot
      • Auto-detects display type from PNG dimensions
      • Reuses existing screenshot sets
  8. scripts/ios-fleet/capture-appstore-screenshots.sh (added) - Main capture script that:

    • Builds for one iPhone and one iPad simulator
    • Seeds realistic quota snapshots
    • Captures three screens per device (fleet-status, platform-order, alarms)
    • Waits for actual render via polling
    • Checks capture sizes match App Store Connect requirements
    • Converts to PNG without alpha channel
  9. scripts/ios-fleet/pick_simulator.py (added) - Picks a simulator matching preferred names

  10. scripts/ios-fleet/screenshot_ink.py (added) - Measures non-background pixel percentage to detect blank frames

  11. scripts/ios-fleet/test_pick_simulator.py (added) - Tests for pick_simulator

  12. scripts/ios-fleet/test_screenshot_ink.py (added) - Tests for screenshot_ink

The code review found 3 issues:

  • 2 high severity: no timeout on Apple's upload URL; failed upload leaves orphaned reserved record
  • 1 medium: Sub-24-byte PNG input throws uncaught RangeError

Now I need to write a clear PR description that summarizes the functional purpose and impact of these changes. I should focus on the what and why, not the how.

Summary

Captures App Store screenshots for the CodeCaps iOS companion on a CI runner and uploads them to App Store Connect as a separate, explicit step.

What changed

  • New GitHub Actions workflow (.github/workflows/ios-appstore-screenshots.yml) that builds the iOS companion on a macOS runner, captures screenshots for one iPhone and one iPad simulator, and publishes them as build artifacts. The App Store Connect upload is deliberately a separate manual step so a re-run cannot silently add duplicate screenshots to a live version.
  • New capture script (scripts/ios-fleet/capture-appstore-screenshots.sh) that builds the companion, seeds each simulator with a realistic quota snapshot, and captures three screens per device (quota list, platform-order editor, reset-alarm settings). It polls the simulator and keeps the first frame that holds more than the status bar (a fixed sleep previously produced blank captures).
  • New upload-screenshots subcommand in scripts/ios-fleet/asc-api.mjs that uploads via Apple's three-call flow (reserve screenshot → PUT bytes to pre-signed URL → commit). It maps captured PNG dimensions to the correct App Store Connect screenshot set (e.g. 1320×2868 → APP_IPHONE_67, 2064×2752 → APP_IPAD_PRO_129), reuses existing screenshot sets rather than creating duplicates, and refuses mixed-family files.
  • New Python helpers with tests:
    • pick_simulator.py — selects an available simulator by prefix (Xcode names iPads with their storage suffix, which a previous exact match missed).
    • screenshot_ink.py — measures the share of non-background pixels so the capture script can tell a drawn screen from a blank one without an image library.
  • iOS companion changes to make the captured screens look correct and reachable:
    • CompanionSourceScope.displayTitle now shows "This iPad" on iPad and "This Mac" on macOS instead of the raw value "This iPhone", so an iPad never displays "This iPhone" in its App Store screenshot.
    • -openAlarms launch argument opens the Alarms settings tab so the screenshot capture can photograph it without a tap.
  • New Swift tests asserting the source-scope label is device-aware and that the -openAlarms argument selects the Alarms tab.
  • Ship-contract workflow now runs the new Python tests; .gitignore ignores __pycache__/ and *.pyc.

Review findings

The automated review flagged two high-severity issues in scripts/ios-fleet/asc-api.mjs and one medium issue that should be addressed before relying on the upload path:

  • The PUT to Apple's pre-signed upload URL has no timeout, so a stalled upload hangs the script indefinitely.
  • If the PUT or the commit PATCH fails, the reserved appScreenshots record is left orphaned in App Store Connect because the error paths do not DELETE it.
  • A PNG smaller than 24 bytes triggers an uncaught RangeError in readUInt32BE because the input is not length-checked.

Summary

Adds a controlled pipeline for generating App Store-ready screenshots of the CodeCaps iOS companion on a macOS runner and publishing them to App Store Connect.

Functional changes

  • Added a capture workflow that:

    • Runs on macos-latest, installs XcodeGen, and builds the companion for preferred iPhone and iPad simulators.
    • Seeds each simulator with realistic quota data so screenshots do not depend on a live sync service.
    • Captures the quota status, platform-order editor, and alarms/settings screens for both device families.
    • Polls simulator frames until meaningful content is rendered, avoiding blank screenshots caused by cold-start rendering delays.
    • Converts captures to PNG, rejects alpha channels, verifies App Store-compatible dimensions and device family, and names files according to their App Store screenshot set.
    • Uploads the resulting screenshots as the appstore-screenshots GitHub Actions artifact.
  • Added simulator-selection and screenshot-content helpers with unit coverage:

    • Simulator matching respects device preference order, availability, and iPad storage-variant names.
    • Screenshot analysis measures non-background content to distinguish blank frames from rendered screens.
  • Added upload-screenshots to scripts/ios-fleet/asc-api.mjs:

    • Determines the App Store Connect display type from PNG dimensions or accepts an explicit type.
    • Rejects unsupported dimensions and mixed iPhone/iPad screenshot sets.
    • Reuses or creates the appropriate screenshot set for a localization.
    • Handles Apple’s reservation, upload, commit, and asset-delivery-state flow, reporting upload errors.
    • Keeps publishing separate from CI so workflow reruns cannot silently add screenshots to a live App Store version.
  • Updated the companion for reliable screenshot capture:

    • Added an -openAlarms launch argument that opens the Alarms tab in the settings sheet.
    • Changed the source-scope picker to display the actual device family—This iPhone, This iPad, or This Mac—without changing the persisted raw value used by existing preferences.
  • Added the new Python suites to the iOS shipping contract workflow and added Swift coverage for device-aware scope labels and the alarms launch argument.

Validation

The shipping contract now runs:

python3 scripts/ios-fleet/test_pick_simulator.py
python3 scripts/ios-fleet/test_screenshot_ink.py

Automated review findings

The automated review reported seven unresolved findings against this change:

  • Critical: App Store Connect authentication errors omit the required masked key preview.
  • High: Workflow path filters omit shared QuotaCore sources and the new capture helpers; App Store Connect responses lack boundary validation before use; empty argument-array expansion under set -u can abort captures on macOS Bash 3.2; and the 10% render threshold can time out settings-sheet captures.
  • Medium: Separate derived-data paths cause two full companion builds, and each render poll converts and scans a full-resolution frame.

Description

  • Updated the iOS App Store screenshot capture script to support launching the app without additional arguments.
  • Optional simulator launch arguments are now expanded only when present, preventing the runner’s Bash 3.2 from exiting with an unbound-variable error under set -u during the initial no-argument capture.
  • This allows screenshot capture to continue successfully through the existing image conversion and App Store Connect upload flow.

Summary

This PR adds a runner-based iOS App Store screenshot pipeline for the CodeCaps Companion, capturing the quota, platform-order, and alarm screens on iPhone and iPad simulators before uploading them to App Store Connect. It also improves capture reliability, upload cleanup, error handling, performance, and test coverage.

Functional changes

  • Expands the screenshot workflow triggers to include the shared QuotaCore files compiled into the companion and the screenshot capture helpers. This keeps regenerated screenshots aligned with shipped app changes without rerunning captures for unrelated Mac-only QuotaCore edits.
  • Refactors the capture script to build the simulator app once and reuse the resulting bundle for both iPhone and iPad captures.
  • Adds render-aware capture thresholds:
    • Quota-list screens require at least 10% non-background content.
    • Settings screens use a 6% threshold.
    • Sparse screens may be retained after two stable readings once they exceed a 4.5% floor.
  • Continues polling simulator frames until the app has drawn instead of relying on a fixed sleep.
  • Optimizes screenshot ink measurement by estimating the dominant background from a strided sample, confirming it against the full channel, and falling back to a complete frequency count when the frame has no dominant background.
  • Emits the measured ink percentage separately from diagnostic output so the capture script can compare successive frames.
  • Adds 120-second timeouts to App Store Connect and upload requests.
  • Adds App Store Connect response-shape validation for screenshot sets, reservations, upload operations, and commits.
  • Prevents raw App Store Connect response bodies from being printed. Errors now expose only limited JSON API titles/details, redact token-like values, and provide a targeted credential-rejection message for HTTP 401/403 responses.
  • Adds best-effort deletion of screenshot reservations after upload failures, commit failures, or missing upload operations so failed attempts do not unnecessarily consume slots in App Store Connect’s ten-screenshot sets.
  • Makes the App Store Connect API base configurable for local integration tests while retaining Apple’s API as the default.

Tests

  • Adds a local HTTP-server integration test for App Store Connect uploads covering:
    • Successful reservation, upload, and commit.
    • Reservation cleanup after failed uploads and commits.
    • Confirmation that cleanup completion is reported.
    • Suppression of response-body secrets.
    • Credential-specific handling for rejected keys.
  • Adds a capture-script contract test covering shell syntax, Bash 3.2 compatibility, render-wait behavior, per-screen thresholds, stable-frame handling, and single-build usage.
  • Adds Python tests for dominant-background sampling, invalid sampled candidates, and frames without a dominant background.
  • Runs the new upload and capture tests as part of the iOS ship contract workflow.

Outstanding automated review findings

The current patch still has unresolved review findings:

  • Critical: The ASC_API_BASE override can redirect the signed App Store Connect bearer token to an arbitrary host.
  • Critical: The rejected-key test checks credential variable names but does not assert the required masked key preview.
  • High: Error objects are not validated before their title and detail fields are read.
  • High: Reservation-shape validation can exit without deleting the newly created reservation.
  • High: App Store Connect timeouts can escape as raw stack traces and skip reservation cleanup.
  • High: The render-measurement helper can abort capture on the first blank frame under set -e.
  • High: The per-simulator derived directory is not created after the build-once refactor, causing screenshot writes to fail.
  • High: The stable-frame shortcut can retain frames below the per-screen minimum thresholds.
  • Low: The request timeout is declared both at module scope and inside main().

Summary

  • Added a minimum-length check before reading PNG dimensions during iOS App Store screenshot uploads.
  • Files smaller than 24 bytes now exit with code 2 and a clear “too small to be a PNG” message instead of producing an unhandled RangeError and stack trace.
  • Existing dimension and display-type validation remains unchanged for files large enough to read.

Review Note

  • The automated review identified that immediately exiting after writing the new error to asynchronous stderr may cause the message to be lost.

Summary

Implements runner-based iOS App Store screenshot capture and App Store Connect uploads, with hardening focused on API failure handling, screenshot reservation cleanup, and blank-frame polling.

Changes

  • Restricts ASC_API_BASE overrides to HTTP(S) loopback addresses for local testing. When no override is provided, requests continue to use Apple’s production App Store Connect endpoint.
  • Catches App Store Connect fetch failures and request timeouts, returning structured non-success results so upload flows can reach existing cleanup and error-handling paths.
  • Safely formats App Store Connect error responses containing null, string, or otherwise malformed error entries.
  • Validates screenshot reservation responses before uploading, reports when a screenshot-set reservation may remain open, and attempts to discard the reservation when an ID is available.
  • Allows below-minimum screenshot ink measurements to remain non-fatal under set -e, allowing the renderer to continue polling for visible output.

Review Follow-ups

  • Transport failures are currently represented as status 0 responses, which probe logic may misreport as an app, screenshot, or existing reservation not being found.
  • A non-loopback ASC_API_BASE aborts startup, including when the production URL is explicitly configured. Loopback overrides with a trailing slash may also generate malformed //v1/... request paths.
  • measure_ink currently suppresses every non-zero exit, including measurement failures, which can delay reporting until the application render timeout.

Summary

This PR hardens the runner-based iOS App Store screenshot capture and upload flow. It prevents partially rendered simulator screens from being captured, distinguishes App Store Connect connectivity failures from lookup failures, protects signed requests from being sent to unintended hosts, and preserves complete error output.

Key changes

Screenshot readiness and capture

  • Ink measurement now reports both:
    • The percentage of the complete frame containing content.
    • The percentage containing content below the status and navigation bars.
  • A screenshot is captured only when it:
    • Meets its screen-specific overall ink threshold.
    • Meets the common 0.5% content-region threshold.
    • Remains stable across repeated polls.
  • Removed the stable-frame path that could accept a sparse frame without meeting its minimum thresholds.
  • Lowered the settings-screen overall threshold from 6% to 5%.
  • Header-only and partially drawn frames are now rejected instead of being mistaken for completed screens.
  • Fatal ink-measurement errors are surfaced immediately rather than being treated as blank frames and consuming the full render timeout.
  • Added a source-only mode so capture decisions can be exercised directly by contract tests.

App Store Connect client

  • ASC_API_BASE now accepts only:
    • The explicit production App Store Connect host.
    • A loopback address for local testing.
  • Trailing slashes are normalized to avoid malformed API paths.
  • Invalid host overrides produce a named error and exit code 2 without a stack trace.
  • Transport failures are tracked separately so bundle lookups do not incorrectly report that an app is missing.
  • A failed newest-build probe now reports the newest version as unknown rather than incorrectly reporting no builds.
  • Immediate process termination was replaced with controlled exit codes so queued diagnostics and large API response bodies are not truncated.
  • Invalid screenshot upload responses now produce concise, named failures while preserving their intended exit status.

Test coverage

  • Added App Store Connect client tests using a synthetic key and local HTTP server, covering:
    • Allowed and rejected API hosts.
    • Trailing-slash normalization.
    • Transport-failure messaging.
    • Diagnostic and large-payload delivery.
  • Added capture contract tests for header-only, partially rendered, finished, and boundary-threshold frames.
  • Added screenshot ink tests for overall versus content-region measurements and BMP dimension handling.
  • Added the App Store Connect client tests to the iOS ship contract workflow.

Known review items

  • An external-build response is still indexed without a shape guard.
  • Ink measurement processes that exit with status 1 are still treated as blank or low-ink readings.
  • Timeout failures are currently described as requests that never reached App Store Connect.
  • A 401 or 403 response during bundle lookup can still be surfaced as an app-not-found result.
  • Sourcing the capture script in the contract test can cause positional test arguments to be used as OUT_DIR.

@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

CC-169

@jaywedgeworth22
jaywedgeworth22 enabled auto-merge (squash) October 19, 2026 16:17
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh Outdated
@kody-ai

This comment has been minimized.

@kody-ai

kody-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

kody code-review Business Logic medium

🤔 Insufficient Task Context

I found a task linked to this PR, but it only contains minimal information (title only, no description or acceptance criteria). To perform a meaningful business rules validation, I need more details.

🔍 What I need to validate:

  • Business requirements and acceptance criteria
  • Expected behavior and business rules
  • Edge cases and constraints to consider

💡 How to improve the task context:

  • Add a description to the linked ticket
  • Include acceptance criteria or business rules
  • Describe the expected behavior after the change

⚠️ Important:

A task title alone is not sufficient to determine whether the implementation is correct or complete.


💡 This validation runs automatically only on the first review of a pull request. To run it again, comment @kody -v business-logic.

Comment thread .github/workflows/ios-appstore-screenshots.yml Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh Outdated
@kody-ai

This comment has been minimized.

@kody-ai

This comment has been minimized.

Comment thread scripts/ios-fleet/asc-api.mjs
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh
@kody-ai

This comment has been minimized.

@jaywedgeworth22
jaywedgeworth22 force-pushed the minimax/ios-appstore-screenshots branch from 06a613d to 67ee26c Compare October 19, 2026 18:20
@kody-ai

This comment has been minimized.

Comment thread scripts/ios-fleet/asc-api.mjs
Comment thread scripts/ios-fleet/asc-api.mjs
Comment thread scripts/ios-fleet/asc-api.mjs
… 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
…s one card

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
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
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.
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.
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.
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.
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.
…ab 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.
@jaywedgeworth22
jaywedgeworth22 force-pushed the minimax/ios-appstore-screenshots branch from 67ee26c to 4b15713 Compare October 20, 2026 06:51
@kody-ai

This comment has been minimized.

Comment thread scripts/ios-fleet/asc-api.mjs Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh Outdated
Comment thread scripts/ios-fleet/test-asc-upload.mjs
kody-ai[bot]
kody-ai Bot previously requested changes Oct 20, 2026

@kody-ai kody-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github-actions Bot and others added 2 commits October 20, 2026 08:39
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 <noreply@anthropic.com>
@kody-ai

This comment has been minimized.

Comment thread scripts/ios-fleet/asc-api.mjs
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 <noreply@anthropic.com>
@kody-ai

This comment has been minimized.

Comment thread scripts/ios-fleet/asc-api.mjs
Comment thread scripts/ios-fleet/asc-api.mjs Outdated
Comment thread scripts/ios-fleet/asc-api.mjs Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh
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.
@kody-ai

This comment has been minimized.

)

* brand: compose the owner mark onto off-white and transparent masters and regenerate the app icon.

* brand: show the owner's mark in colour inside the app.

The About and Settings pages drew the menu bar template icon, which is the
owner's black artwork rendered as a mask.  In the app it now uses the owner's
true vector, in colour on a transparent background, with the dark needle and
the six dark dots as the owner drew them.

The menu bar itself keeps the template icon, because a template mask is what
makes one asset correct in both appearances, and the Glance and PiP marks stay
on it too, where colour at twelve and fifteen points is not legible.

Dark appearance puts an off-white rounded ground behind the mark so the dark
needle and dots do not disappear into the background.

The project memory described the old blue three-dimensional squircle as the
brand and is corrected to the mark that is actually shipped.
Comment thread scripts/ios-fleet/asc-api.mjs Outdated
Comment thread scripts/ios-fleet/asc-api.mjs
Comment thread scripts/ios-fleet/asc-api.mjs Outdated
Comment thread scripts/ios-fleet/capture-appstore-screenshots.sh
Comment thread scripts/ios-fleet/test-capture-script.sh
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.
@kody-ai

kody-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

PR Summary (Comment created by Kody 🤖)

Code Review Started! 🚀

✋ Hi, team! I'm already looking at the changed files and starting the review to ensure everything is in order. If you need more details, I'm here! Kody

📂 Changed Files
File Status ➕ Additions ➖ Deletions 🔄 Changes
scripts/ios-fleet/asc-api.mjs modified 45 15 60
scripts/ios-fleet/capture-appstore-screenshots.sh modified 5 1 6
scripts/ios-fleet/screenshot_ink.py modified 17 1 18
scripts/ios-fleet/test-asc-client.mjs modified 59 0 59
scripts/ios-fleet/test-capture-script.sh modified 15 3 18
scripts/ios-fleet/test_screenshot_ink.py modified 27 0 27
📊 Summary of Changes
  • Total Files: 6
  • Total Lines Added: 168
  • Total Lines Removed: 20
  • Total Changes: 188

​

@jaywedgeworth22
jaywedgeworth22 dismissed stale reviews from kody-ai[bot] and kody-ai[bot] October 20, 2026 21:16

Every finding in this review is addressed: the code it points at is fixed, the threads are replied to and resolved, and the required checks are green. The review is against a head that no longer exists, and the reviewer has not requested changes again since. Dismissed so the pull request can merge.

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 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant