Skip to content

iOS: public App Store lane (com.cmux.app), privacy manifest, fastlane screenshots - #6697

Merged
lawrencecchen merged 90 commits into
mainfrom
feat-ios-appstore-prod-lane
Jul 14, 2026
Merged

lawrencecchen merged 90 commits into
mainfrom
feat-ios-appstore-prod-lane

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Prepares cmux iOS for a public App Store release under a clean new bundle id, alongside the existing dev.cmux.app.beta dogfood channel. App Store Connect app record cmux / com.cmux.app and the cmux Distribution profile are already created; a v1.0 build is uploaded and processing.

Changed

  • Privacy manifest: ios/cmux/PrivacyInfo.xcprivacy wired into the app target. NSPrivacyTracking=false; required-reason APIs UserDefaults (CA92.1) + file-timestamp (DDA9.1); product-interaction analytics label. (No Sentry/IDFA in the iOS app.)
  • Prod lane: appstore lane in ios/scripts/upload-testflight.sh and ios/scripts/cloud-testflight.sh → com.cmux.app, on-device name cmux, cmux Distribution profile. Reuses the existing release entitlements and cmux-ios URL scheme; beta lane unchanged.
  • Push routing: com.cmux.app added to PROD_BUNDLE_IDS in web/services/apns/routePolicy.ts (+ test) so production pushes route correctly.
  • Build type: MobileBuildType doc/test note that com.cmux.app resolves to .prod (behavior already covered by the else branch).
  • Fastlane screenshots: ios/fastlane/ (Snapfile/Fastfile/Appfile) capturing en-US + ja on the 6.9" iPhone + 13" iPad classes, driven by the existing CMUX_UITEST_MOCK_DATA DEBUG hook via SnapshotUITests.
  • Screenshots CI: .github/workflows/ios-screenshots.yml captures on a DEBUG build (no signing), resolving the required iPhone/iPad device classes at runtime so it survives Xcode version bumps; optional ASC upload on workflow_dispatch.

Images

ios/fastlane/frame_assets/backgrounds/l/00.jpg ios/fastlane/frame_assets/backgrounds/l/00.jpg
ios/fastlane/frame_assets/backgrounds/l/01.jpg ios/fastlane/frame_assets/backgrounds/l/01.jpg
ios/fastlane/frame_assets/backgrounds/l/02.jpg ios/fastlane/frame_assets/backgrounds/l/02.jpg
ios/fastlane/frame_assets/backgrounds/l/03.jpg ios/fastlane/frame_assets/backgrounds/l/03.jpg
ios/fastlane/frame_assets/backgrounds/l/04.jpg ios/fastlane/frame_assets/backgrounds/l/04.jpg
ios/fastlane/frame_assets/backgrounds/l/05.jpg ios/fastlane/frame_assets/backgrounds/l/05.jpg
ios/fastlane/frame_assets/backgrounds/p/00.jpg ios/fastlane/frame_assets/backgrounds/p/00.jpg
ios/fastlane/frame_assets/backgrounds/p/01.jpg ios/fastlane/frame_assets/backgrounds/p/01.jpg
ios/fastlane/frame_assets/backgrounds/p/02.jpg ios/fastlane/frame_assets/backgrounds/p/02.jpg
ios/fastlane/frame_assets/backgrounds/p/03.jpg ios/fastlane/frame_assets/backgrounds/p/03.jpg
ios/fastlane/frame_assets/backgrounds/p/04.jpg ios/fastlane/frame_assets/backgrounds/p/04.jpg
ios/fastlane/frame_assets/backgrounds/p/05.jpg ios/fastlane/frame_assets/backgrounds/p/05.jpg
ios/fastlane/frame_assets/bg_landscape.jpg ios/fastlane/frame_assets/bg_landscape.jpg
ios/fastlane/frame_assets/bg_portrait.jpg ios/fastlane/frame_assets/bg_portrait.jpg
ios/fastlane/frame_assets/logos/Claude.png ios/fastlane/frame_assets/logos/Claude.png
ios/fastlane/frame_assets/logos/Codex.png ios/fastlane/frame_assets/logos/Codex.png
ios/fastlane/frame_assets/logos/OpenCode.png ios/fastlane/frame_assets/logos/OpenCode.png
ios/fastlane/frame_assets/logos/Pi.png ios/fastlane/frame_assets/logos/Pi.png

Check

  • web-typecheck / web tests: normalizeApnsBundle("com.cmux.app") → production.
  • test-ios: app builds with the privacy manifest bundled.
  • ios-screenshots (this PR triggers capture-only): snapshot test runs and produces iPhone + iPad screenshots in en-US + ja.

Docs/metadata note: this is the iOS App Store enablement; no macOS runtime behavior changes.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Prod upload and ASC screenshot upload use signing/API secrets with main-only guards; the streamed-validation job is large, long-running, and depends on many CI secrets and seeded fake agent auth.

Overview
Adds three GitHub Actions workflows for public iOS App Store release automation on macOS runners.

ios-appstore-upload.yml is dispatch-only on main, with serialized concurrency. It materializes ASC API keys, imports the distribution cert and prod provisioning profile (validates com.cmux.app and aps-environment=production), then archives and uploads via ./ios/scripts/upload-testflight.sh with --lane appstore, manual signing, optional marketing/build numbers, and --skip-notes.

ios-screenshots.yml runs fastlane capture for en-US + ja on runtime-resolved 6.9" iPhone and 13" iPad simulators (DEBUG / mock data, no signing for capture). PRs touching screenshot paths get capture-only; ASC upload is dispatch + upload=true, blocked unless main with no ref override.

ios-streamed-validate.yml is a headless E2E check for the Mac-streamed screenshot path: native Postgres + Next dev, tagged Mac/iOS sim builds, agent CLI seeding, pairing, and capture-streamed.py, with diagnostic artifacts on failure.

Reviewed by Cursor Bugbot for commit d0f3a4f. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Prepares a public iOS App Store build under com.cmux.app with a privacy manifest, a parallelized framed fastlane screenshots pipeline (preserves raw captures and writes an HTML gallery), streamed validation, and a dispatch‑only prod upload. Also fixes the screenshot preview’s terminal keyboard‑height parsing.

  • New Features

    • Public lane: --lane appstore in ios/scripts/upload-testflight.sh and cloud-testflight.sh targets com.cmux.app with the cmux Distribution profile; APNs routes to production; MobileBuildType resolves .prod (test added).
    • Privacy + labeling: ios/cmux/PrivacyInfo.xcprivacy (NSPrivacyTracking=false, required‑reason APIs) and a product‑interaction analytics label.
    • Localization + metadata: app declares en + ja; App Store listing copy added for ~14 locales; notification permission/banner strings localized.
    • Screenshots + CI: fastlane screenshots captures 6.9" iPhone + 13" iPad (dark mode), replays recorded agent sessions with auto‑derived terminal background, shows a real iOS notification banner, stitches a real iPhone frame with an opening mask, renders SF Pro headers with inline agent logos, paints a unified Dynamic Island, varies per‑screen backgrounds, writes an HTML gallery, preserves raw captures alongside framed images, and parallelizes framing for faster CI; workflows ios-screenshots.yml, ios-streamed-validate.yml, and ios-appstore-upload.yml added; prod upload is dispatch‑only on main; streamed‑validation secrets are restricted to main.
  • Migration

    • Public build: ios/scripts/upload-testflight.sh --lane appstore (or cloud), or run “iOS App Store (prod upload)” with marketing_version/build_number.
    • Screenshots: cd ios && fastlane screenshots; to upload, run with upload=true and ASC API key env; overrides: SNAPSHOT_DEVICES, SNAPSHOT_LANGUAGES, CMUX_UITEST_TERMINAL_TARGET_COLS.

Written for commit 8e690b3. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features
    • Added an automated iOS App Store screenshot workflow (Fastlane capture and optional upload).
    • Added an iOS privacy manifest.
    • Expanded iOS Store metadata with additional localized descriptions/keywords and new app metadata files.
  • Bug Fixes
    • Improved App Store/TestFlight production handling and bundle identifier expectations.
    • Tightened production upload validation to require aps-environment=production.
  • Tests
    • Added and wired SwiftUI UI snapshot helpers and streamlined the iOS snapshot test flow.
  • Documentation
    • Updated privacy-related messaging in the iOS privacy descriptions.

… screenshots

Prep cmux iOS for a public App Store release alongside the existing
dev.cmux.app.beta dogfood channel.

- PrivacyInfo.xcprivacy wired into the app target: NSPrivacyTracking=false,
  UserDefaults (CA92.1) + file-timestamp (DDA9.1) reasons, product-interaction
  analytics label. No Sentry/IDFA in iOS.
- upload-testflight.sh + cloud-testflight.sh: new appstore lane
  (com.cmux.app, on-device name "cmux", cmux Distribution profile), sharing the
  existing release entitlements and cmux-ios URL scheme.
- web/services/apns/routePolicy.ts: route com.cmux.app to production APNs (+ test).
- MobileBuildType: document/test com.cmux.app as a prod bundle id.
- ios/fastlane: snapshot config (en-US + ja; iPhone 6.9" + iPad 13") driving the
  CMUX_UITEST_MOCK_DATA DEBUG state via a SnapshotUITests case.
- .github/workflows/ios-screenshots.yml: capture screenshots in CI on a DEBUG
  build (no signing), resolving the required iPhone/iPad classes at runtime;
  optional upload to App Store Connect on workflow_dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 14, 2026 9:56pm
cmux-staging Building Building Preview, Comment Jul 14, 2026 9:56pm

@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds end-to-end App Store screenshot automation via a new GitHub Actions workflow, fastlane screenshots/upload_screenshots lanes, SnapshotHelper.swift, and SnapshotUITests.swift. Extends distribution scripts with an appstore lane including APS entitlement verification. Adds PrivacyInfo.xcprivacy, expands localization strings and App Store metadata to 14 languages, and aligns com.cmux.app bundle ID across iOS and web APNs. Enhances terminal preview with sample transcript injection for testing.

Changes

iOS App Store Readiness

Layer / File(s) Summary
Bundle ID alignment and privacy manifest
Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift, Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift, web/services/apns/routePolicy.ts, web/tests/apns.test.ts, ios/cmux/PrivacyInfo.xcprivacy, ios/cmux-ios.xcodeproj/project.pbxproj
Documentation updated to name com.cmux.app explicitly; APNs test expectation fixed for that bundle ID; PROD_BUNDLE_IDS reformatted with comments. New PrivacyInfo.xcprivacy manifest declares tracking disabled, product-interaction collected data, and user-defaults/file-timestamp API access reasons; wired into Xcode as a resource. Xcode project expanded with 12 new locale identifiers in knownRegions.
Multilingual privacy and usage description strings
ios/cmux/Resources/InfoPlist.xcstrings
Updated all five iOS privacy usage description keys (camera, local network, microphone, photo library, speech recognition) with translated strings across 14 locales: de, en, es, fr, it, ja, ko, nl, pl, pt-BR, ru, tr, zh-Hans, zh-Hant.
XCTest snapshot test infrastructure
ios/cmuxUITests/SnapshotHelper.swift, ios/cmuxUITests/SnapshotUITests.swift, ios/cmux-ios.xcodeproj/project.pbxproj
SnapshotHelper.swift adds open Snapshot class with setupSnapshot/snapshot entry points, language/locale/launch-argument injection from cache files, screenshot capture with orientation normalization, loading indicator wait, and XCUIElement classifier extensions. SnapshotUITests.swift drives three-screen capture (01-Workspaces, 02-Terminal, 03-Terminal-Keyboard) with settle() UI wait and app launcher termination between captures. Both files wired into cmuxUITests Xcode target.
Fastlane screenshots and upload configuration
ios/fastlane/Appfile, ios/fastlane/Fastfile, ios/fastlane/Snapfile, ios/fastlane/.gitignore
Appfile sets com.cmux.app identity and team IDs. Fastfile adds screenshots lane using snapshot action with env-driven devices/languages, upload_screenshots lane using deliver for screenshots-only upload, and asc_api_key helper resolving from environment variables or repo-local plist. Snapfile configures iPhone 6.9-inch and iPad 13-inch targets, en-US/ja locales, and deterministic single-simulator execution. .gitignore excludes screenshots folder and Fastlane run artifacts.
App Store metadata in 13 languages
ios/fastlane/metadata/{de-DE,en-US,es-ES,fr-FR,it,ja,ko,nl-NL,pl,pt-BR,ru,tr,zh-Hans,zh-Hant}/*
Adds complete App Store listing metadata (descriptions, keywords, marketing/support URLs, names, subtitles, promotional text) for 13 locales. Each provides marketing copy describing cmux as a terminal for coding agents, with features (live streaming, notifications, on-the-go control, voice-to-text, secure pairing) and licensing/requirements information aligned across all languages.
Appstore lane in distribution scripts
ios/scripts/cloud-testflight.sh, ios/scripts/upload-testflight.sh
cloud-testflight.sh adds --lane beta|appstore parsing and lane-resolution block for bundle ID and display name selection. upload-testflight.sh adds verify_ipa_aps_environment_production enforcing aps-environment=production via strict codesign and entitlement extraction; extends lane configuration for appstore/prod path with public bundle ID, production provisioning profile, and DISPLAY_NAME_ARGS injected into both xcodebuild archive invocations.
CI workflow for screenshot capture and upload
.github/workflows/ios-screenshots.yml
New workflow triggered by workflow_dispatch (ref, languages, upload inputs) and pull_request on iOS snapshot paths. Sets up macOS runner with Xcode, Zig, iOS simulator runtime, GhosttyKit, and resolves SNAPSHOT_DEVICES via simctl. Always runs fastlane screenshots and uploads artifact with 14-day retention; conditionally decodes ASC API key and runs fastlane upload_screenshots on manual dispatch when upload=true.
Terminal preview sample content for testing
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift
Replaces no-op updateUIView with logic to conditionally feed sampleTranscript (ANSI-colored coding-agent session) into GhosttySurfaceView when CMUX_UITEST_TERMINAL_PREVIEW_CONTENT=1, preview has valid bounds, and content has not been fed before. Adds didFeedContent flag to Coordinator for single-injection tracking.

Sequence Diagram(s)

sequenceDiagram
  participant GHActions as GitHub Actions
  participant Fastlane
  participant Simulator as iOS Simulator
  participant SnapshotUITests
  participant Snapshot
  participant ASC as App Store Connect

  GHActions->>GHActions: resolve SNAPSHOT_DEVICES via simctl
  GHActions->>Fastlane: fastlane screenshots (SNAPSHOT_LANGUAGES, SNAPSHOT_DEVICES)
  Fastlane->>Simulator: launch cmux-ios scheme DEBUG build
  Simulator->>SnapshotUITests: run testCaptureAppStoreScreenshots
  SnapshotUITests->>Snapshot: setupSnapshot(app) — inject language/locale
  SnapshotUITests->>Snapshot: snapshot("01-Workspaces")
  Snapshot->>Simulator: XCUIScreen.main.screenshot()
  Snapshot->>Snapshot: write PNG to fastlane/screenshots/
  SnapshotUITests->>Snapshot: snapshot("02-Terminal")
  SnapshotUITests->>Snapshot: snapshot("03-Terminal-Keyboard")
  Fastlane-->>GHActions: screenshots artifact uploaded
  GHActions->>GHActions: decode ASC_API_KEY secret (if upload=true)
  GHActions->>Fastlane: fastlane upload_screenshots
  Fastlane->>ASC: deliver screenshots (skip binary/metadata)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#6131: Both modify ios/scripts/upload-testflight.sh to ensure the exported IPA contains the correct aps-environment=production entitlement, with the retrieved PR archiving with entitlements config and this PR adding strict verification gates.
  • manaflow-ai/cmux#6141: Also modifies iOS TestFlight signing/upload pipeline in ios/scripts/upload-testflight.sh around aps-environment=production entitlement handling, overlapping with the new verify_ipa_aps_environment_production verification added here.
  • manaflow-ai/cmux#5485: Both involve naming the iOS build via PRODUCT_DISPLAY_NAME for TestFlight—the retrieved PR hard-sets Release display name to "cmux BETA", while this PR updates TestFlight/cloud scripts to pass a lane-specific PRODUCT_DISPLAY_NAME into xcodebuild.

Poem

🐇 Hop hop, the rabbit snaps a screen,
Six-point-nine inch iPhone, crisp and clean!
Privacy manifest seals the deal,
Appstore lane — the real reveal.
Fastlane workflows, screenshots bright,
Thirteen languages shining light! 🌸

🚥 Pre-merge checks | ✅ 24 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (24 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PR adds no production Swift code with actor isolation issues. SnapshotHelper.swift and SnapshotUITests.swift are test code; TerminalLayoutPreviewView changes are DEBUG-only; MobileBuildType.swift c...
Cmux Swift Blocking Runtime ✅ Passed Production Swift code contains no blocking/timing-based sync; test-only files have deterministic screenshot scaffolding sleeps (animation wait, UI settle).
Cmux Browser Automation Off-Main ✅ Passed PR contains no browser automation changes. The iOS App Store release PR modifies only iOS code, fastlane config, localization, and APNs routing—no processV2Command, socketWorkerMethods, mainActor,...
Cmux Expensive Synchronous Load ✅ Passed PR adds only test harness and hardcoded sample data, no expensive synchronous agent-history loads on main actor or interactive paths.
Cmux Cache Substitution Correctness ✅ Passed PR contains no cache substitution swaps in persistence/history/undo/snapshot paths. SnapshotHelper uses transient test caches (allowed exception); sample transcript is purely transient. Other chang...
Cmux No Hacky Sleeps ✅ Passed PR adds sleeps only in Swift files (SnapshotHelper.swift, SnapshotUITests.swift), which are explicitly out of scope per rule; Swift timing is covered separately by swift-blocking-runtime.md. No sle...
Cmux Algorithmic Complexity ✅ Passed PR introduces no algorithmic complexity violations: Fastlane/Ruby config has no loops; test code operates on fixed-size XCTest fixtures; device resolution handles bounded simulator list (~50 items,...
Cmux Swift Concurrency ✅ Passed New Swift code introduces no legacy async patterns in cmux-owned production code. SnapshotHelper.swift is third-party (fastlane). SnapshotUITests.swift contains only test-synchronization with XCTes...
Cmux Swift @Concurrent ✅ Passed All Swift code changes contain only synchronous functions properly isolated with @MainActor for UI work; no async/await or @concurrent violations detected.
Cmux Swift File And Package Boundaries ✅ Passed SnapshotHelper.swift (313 lines) is vendored fastlane snapshot testing code (v1.30); SnapshotUITests.swift (67 lines) is a small test fixture. Both fall under allowed exceptions for generated/vendo...
Cmux Swiftpm Lockfiles ✅ Passed PR does not violate SwiftPM lockfile rules: no package .gitignore ignores Package.resolved, no new SwiftPM dependencies, and no package-reference changes requiring lockfile diffs.
Cmux Swift Logging ✅ Passed NSLog in SnapshotHelper.swift is allowed per rules: test fixture code in UITests harness where stdout is part of the harness; no secrets/personal data exposed.
Cmux User-Facing Error Privacy ✅ Passed Environment variable names (ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_KEY_PATH) mentioned in error messages are only in developer-only operational tooling (GitHub Actions workflow, Fastlane build...
Cmux Full Internationalization ✅ Passed All user-facing text changes properly localized: 14-locale Fastlane metadata coverage and InfoPlist.xcstrings with complete translations for all 5 privacy keys; new Swift code contains only debug/t...
Cmux Swiftui State Layout ✅ Passed No SwiftUI state violations found. TerminalLayoutPreviewView uses proper UIViewRepresentable pattern with stateless View wrapper and Coordinator that mutates state only in delegate callbacks (not r...
Cmux Architecture Rethink ✅ Passed Swift changes use test-only synchronization (fastlane snapshot harness, XCTest infrastructure) and required platform bridge code (UIViewRepresentable delegate) with clear owners and invariants; no...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed This PR only modifies iOS code (ios/ and Packages/iOS/) and is not subject to the macOS auxiliary window close shortcuts rule, which applies to Sources/ (macOS app only) with NSWindow/NSPanel/NSWin...
Cmux Source Artifacts ✅ Passed All PR files comply with source-control-artifacts.md: intentional source/test/config files and product docs added; generated artifacts properly gitignored; no caches, temp dirs, or build output.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Production source files in this PR comply with the no-test-debug-seam rule: MobileBuildType.swift has documentation-only changes; TerminalLayoutPreviewView.swift is an existing DEBUG-only facility...
Cmux No Ambient Global State ✅ Passed No ambient global state violations: new Swift files are either test code (SnapshotHelper.swift, SnapshotUITests.swift in ios/cmuxUITests/) exempt from the rule, or DEBUG-only code (TerminalLayoutPr...
Title check ✅ Passed The title is concise and accurately highlights the main App Store lane, privacy manifest, and screenshot work.
Description check ✅ Passed The description covers summary, testing, and review trigger details; the demo video and checklist sections are missing but non-critical.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-appstore-prod-lane

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread .github/workflows/ios-screenshots.yml Outdated
@greptile-apps

greptile-apps Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Prepares cmux iOS for a public App Store release under com.cmux.app, alongside the existing dev.cmux.app.beta channel. The infrastructure changes (APNs routing, upload scripts, privacy manifest) are clean and correct; the screenshot capture pipeline is the bulk of the diff.

  • Production lane + APNs routing: upload-testflight.sh and cloud-testflight.sh gain an appstore lane targeting com.cmux.app with the cmux Distribution profile; routePolicy.ts adds com.cmux.app to PROD_BUNDLE_IDS with a matching test; MobileBuildType docs note the new bundle ID maps to .prod.
  • Privacy manifest: PrivacyInfo.xcprivacy declares NSPrivacyTracking=false, UserDefaults (CA92.1), file-timestamp (DDA9.1), and product-interaction analytics — all consistent with stated app behavior.
  • Screenshot pipeline: CI workflow resolves iPhone/iPad simulator names at runtime to survive Xcode version bumps; fastlane screenshots + frameit run on a DEBUG build with mock data; optional ASC upload is gated on workflow_dispatch. Three new debug-only SwiftUI types (ScreenshotKeyboardView, ScreenshotNotificationBanner, TerminalPreviewTranscripts) and extended env-var knobs in TerminalLayoutPreviewView support the screenshot fixtures, but are placed in production Sources/ rather than the test target.

Confidence Score: 4/5

Safe to merge with the screenshot-fixture placement question resolved; the production distribution, APNs routing, and privacy manifest changes are straightforward and well-tested.

The production-path changes (APNs routing, upload scripts, privacy manifest) are minimal, correct, and covered by tests. The screenshot pipeline works as described. Three new types — ScreenshotKeyboardView, ScreenshotNotificationBanner, and TerminalPreviewTranscripts — and additional env-var reading in TerminalLayoutPreviewView are placed in production Sources/CmuxMobileShellUI/ wrapped entirely in #if DEBUG with no production callers. They exist solely to serve the snapshot test harness in ios/cmuxUITests/, which is where they belong per the repo's established rule.

Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotKeyboardView.swift, ScreenshotNotificationBanner.swift, TerminalPreviewTranscripts.swift, and the new additions to TerminalLayoutPreviewView.swift — all debug-only screenshot scaffolding currently in production Sources.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotKeyboardView.swift New debug-only view for screenshot keyboard rendering; lives in production Sources wrapped in #if DEBUG with no production callers — violates no-test-debug-seam rule.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotNotificationBanner.swift New debug-only notification banner view for screenshots; same placement issue as ScreenshotKeyboardView — in production Sources entirely guarded by #if DEBUG.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPreviewTranscripts.swift New debug-only caseless enum holding screenshot transcript data; lives in production Sources wrapped in #if DEBUG with no production callers.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift Modified to add effectiveKeyboardHeight() static func and screenshot-control env-var reads; deepens existing debug seam in production Sources.
web/services/apns/routePolicy.ts Adds com.cmux.app to PROD_BUNDLE_IDS so production APNs pushes route correctly to the new App Store build; change is minimal and correct.
web/tests/apns.test.ts Adds test confirming com.cmux.app normalizes to production environment; coverage is appropriate.
.github/workflows/ios-screenshots.yml New CI workflow for App Store screenshot capture; device resolution is done at runtime to avoid Xcode version drift; optional ASC upload correctly gated on workflow_dispatch + secrets.
ios/scripts/upload-testflight.sh Adds appstore lane targeting com.cmux.app with the cmux Distribution profile; beta lane unchanged; display name override correctly threaded through both archive invocations.
ios/cmux/PrivacyInfo.xcprivacy New privacy manifest with NSPrivacyTracking=false, UserDefaults CA92.1, file-timestamp DDA9.1, and product-interaction analytics; declarations appear correct for stated usage.
ios/cmuxUITests/SnapshotUITests.swift New UI test for App Store screenshot capture; lives correctly in the test target; Thread.sleep settle delay is test-only scaffolding.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[ios/scripts/upload-testflight.sh] -->|--lane beta| B[dev.cmux.app.beta\ncmux Beta Distribution]
    A -->|--lane appstore| C[com.cmux.app\ncmux Distribution]
    D[ios/scripts/cloud-testflight.sh] -->|--lane beta| B
    D -->|--lane appstore| C
    C --> E[TestFlight\ncom.cmux.app record]
    B --> F[TestFlight\nbeta record]
    C --> G[web/services/apns/routePolicy.ts\nPROD_BUNDLE_IDS]
    B --> G
    G -->|production| H[APNs production host]
    G -->|sandbox| I[APNs sandbox host]
    J[.github/workflows/ios-screenshots.yml] -->|fastlane screenshots| K[DEBUG build\nCMUX_UITEST_MOCK_DATA]
    K --> L[SnapshotUITests\niPhone 6.9 + iPad 13]
    L -->|workflow_dispatch + upload=true| M[App Store Connect\ncom.cmux.app screenshots]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[ios/scripts/upload-testflight.sh] -->|--lane beta| B[dev.cmux.app.beta\ncmux Beta Distribution]
    A -->|--lane appstore| C[com.cmux.app\ncmux Distribution]
    D[ios/scripts/cloud-testflight.sh] -->|--lane beta| B
    D -->|--lane appstore| C
    C --> E[TestFlight\ncom.cmux.app record]
    B --> F[TestFlight\nbeta record]
    C --> G[web/services/apns/routePolicy.ts\nPROD_BUNDLE_IDS]
    B --> G
    G -->|production| H[APNs production host]
    G -->|sandbox| I[APNs sandbox host]
    J[.github/workflows/ios-screenshots.yml] -->|fastlane screenshots| K[DEBUG build\nCMUX_UITEST_MOCK_DATA]
    K --> L[SnapshotUITests\niPhone 6.9 + iPad 13]
    L -->|workflow_dispatch + upload=true| M[App Store Connect\ncom.cmux.app screenshots]
Loading

Reviews (12): Last reviewed commit: "ios-screenshots: fix MainActor setupSnap..." | Re-trigger Greptile

run: |
set -euo pipefail
# Use the Homebrew fastlane (ships its own Ruby) instead of bundler:
# macOS system Ruby is 2.6 but fastlane needs >= 3.0, so `bundle install`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 $SNAPSHOT_DEVICES is always empty at this echo

The Python heredoc writes SNAPSHOT_DEVICES=… to $GITHUB_ENV, which only takes effect in subsequent steps — not in the current step's shell. The echo "Resolved SNAPSHOT_DEVICES=$SNAPSHOT_DEVICES" line therefore always prints "Resolved SNAPSHOT_DEVICES=" and provides no useful diagnostic. The fastlane screenshots step in the next step correctly receives the variable, so there is no functional breakage, but the misleading output could confuse anyone debugging a CI capture failure.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ios/scripts/upload-testflight.sh (2)

43-50: 🩺 Stability & Availability | 🟡 Minor

The rc=$? capture is unreachable under set -euo pipefail when PlistBuddy fails.

When the PlistBuddy command returns non-zero (e.g., key absent), the assignment aps="$(..." exits the function before rc=$? runs. This prevents the diagnostic on lines 45–46 from printing. Both call sites already use the if ! exempt pattern, so the function is invoked correctly, but the function's internal logic fails to capture the exit code as intended.

Use aps="$(/usr/libexec/PlistBuddy -c 'Print :aps-environment' "$ent" 2>/dev/null)" || rc=$? to capture the failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/scripts/upload-testflight.sh` around lines 43 - 50, The exit code capture
pattern in the PlistBuddy command block uses an unreachable `rc=$?` statement
that never executes under `set -euo pipefail`. When PlistBuddy fails and returns
non-zero, the command substitution in the `aps="$(..."` assignment causes the
function to exit before the subsequent `rc=$?` line can run, preventing the
error diagnostics from being printed. Fix this by modifying the assignment to
use the OR operator pattern: change `aps="$(/usr/libexec/PlistBuddy ...)"`
followed by `rc=$?` on the next line to instead combine them as
`aps="$(/usr/libexec/PlistBuddy ...)" || rc=$?` so the exit code is properly
captured when the command fails.

18-23: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

unzip runs inside $workdir, so a relative IPA path won't be found.

( cd "$workdir" && unzip -q "$ipa" ) resolves $ipa relative to the temp dir, not the caller's CWD. If verify_ipa_aps_environment_production is ever passed a relative path, the unzip fails and you get a misleading "could not unzip IPA" error rather than verifying entitlements. Drop the cd and let unzip write to the temp dir via -d:

🛠️ Proposed fix
-  workdir="$(mktemp -d)"
-  if ! ( cd "$workdir" && unzip -q "$ipa" ); then
+  workdir="$(mktemp -d)"
+  if ! unzip -q "$ipa" -d "$workdir"; then

Confirm whether existing callers always pass an absolute path:

#!/bin/bash
rg -nP '\bverify_ipa_aps_environment_production\b' ios/scripts/upload-testflight.sh
# Inspect how the argument (IPA path) is constructed before the call
rg -nP '\b(IPA|ipa|EXPORT_IPA|exported_ipa)\w*=' ios/scripts/upload-testflight.sh
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/scripts/upload-testflight.sh` around lines 18 - 23, In the
verify_ipa_aps_environment_production function, the unzip command currently runs
inside a subshell that changes directory to $workdir, which causes relative IPA
paths to be resolved incorrectly. Remove the cd "$workdir" && part from the
unzip line and instead use the -d flag to specify the destination directory: use
unzip -q -d "$workdir" "$ipa" so that $ipa is resolved relative to the caller's
current working directory instead of relative to the temp directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ios-screenshots.yml:
- Around line 90-117: The issue is that the Python script output within the
heredoc is redirected to $GITHUB_ENV, but this does not populate the
SNAPSHOT_DEVICES variable in the current shell environment. When the echo
command on line 116 tries to expand $SNAPSHOT_DEVICES, it fails under set -u
because the variable is undefined in the current shell. You need to capture the
Python script's output into a shell variable first, export or declare it in the
current shell environment, and then write it to $GITHUB_ENV so the variable is
available both immediately and in subsequent workflow steps. This ensures that
the echo command referencing $SNAPSHOT_DEVICES will have access to the resolved
device names.
- Around line 13-40: The ios-screenshots workflow lacks a concurrency group
configuration, which allows multiple simultaneous runs to overlap and compete
for macOS runners while also creating race conditions during screenshot uploads.
Add a concurrency block after the permissions section and before the jobs
section in the .github/workflows/ios-screenshots.yml file that defines a
concurrency group to ensure only one workflow run executes at a time, using a
group identifier that prevents overlapping executions and sets
cancel-in-progress to true to terminate outdated runs when new ones are
triggered.

In `@ios/cmuxUITests/SnapshotUITests.swift`:
- Around line 51-53: The composeButton lookup is using an incorrect primary
accessibility identifier terminal.inputAccessory.composeButton when the correct
app-side identifier is terminal.inputAccessory.composer. Update the ternary
expression that assigns to composeButton to check for
terminal.inputAccessory.composer first as the primary identifier, then fall back
to the NSPredicate matching for identifiers containing "compose" as a secondary
option. This ensures the correct control is found reliably.
- Around line 33-48: The test currently passes even when required UI elements
are missing because the conditions checking workspaceList.waitForExistence and
terminalSurface.waitForExistence silently continue if they return false instead
of failing the test. Replace the if statements that guard the snapshot calls for
workspaceList and terminalSurface with assertions or test failures that
explicitly require these UI elements to exist within their timeouts. This
ensures the test will fail when required screenshots cannot be captured,
preventing the CI from passing with empty or incomplete snapshot artifacts.

In `@ios/scripts/upload-testflight.sh`:
- Around line 296-299: The empty DISPLAY_NAME_ARGS array expansion breaks under
set -u strict mode on macOS bash 3.2 when PRODUCT_DISPLAY_NAME_OVERRIDE is not
set. Find the two archive call sites that expand DISPLAY_NAME_ARGS (referenced
at lines 549 and 572) where "${DISPLAY_NAME_ARGS[@]}" is used, and apply the
conditional expansion guard by changing the expansion to use
"${DISPLAY_NAME_ARGS[@]:-}" to safely handle the empty array case across bash
versions.

---

Outside diff comments:
In `@ios/scripts/upload-testflight.sh`:
- Around line 43-50: The exit code capture pattern in the PlistBuddy command
block uses an unreachable `rc=$?` statement that never executes under `set -euo
pipefail`. When PlistBuddy fails and returns non-zero, the command substitution
in the `aps="$(..."` assignment causes the function to exit before the
subsequent `rc=$?` line can run, preventing the error diagnostics from being
printed. Fix this by modifying the assignment to use the OR operator pattern:
change `aps="$(/usr/libexec/PlistBuddy ...)"` followed by `rc=$?` on the next
line to instead combine them as `aps="$(/usr/libexec/PlistBuddy ...)" || rc=$?`
so the exit code is properly captured when the command fails.
- Around line 18-23: In the verify_ipa_aps_environment_production function, the
unzip command currently runs inside a subshell that changes directory to
$workdir, which causes relative IPA paths to be resolved incorrectly. Remove the
cd "$workdir" && part from the unzip line and instead use the -d flag to specify
the destination directory: use unzip -q -d "$workdir" "$ipa" so that $ipa is
resolved relative to the caller's current working directory instead of relative
to the temp directory.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d0b216d-c6df-4374-8b50-c4a6442e810b

📥 Commits

Reviewing files that changed from the base of the PR and between 510ff81 and 651c241.

📒 Files selected for processing (14)
  • .github/workflows/ios-screenshots.yml
  • Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift
  • Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift
  • ios/cmux-ios.xcodeproj/project.pbxproj
  • ios/cmux/PrivacyInfo.xcprivacy
  • ios/cmuxUITests/SnapshotHelper.swift
  • ios/cmuxUITests/SnapshotUITests.swift
  • ios/fastlane/Appfile
  • ios/fastlane/Fastfile
  • ios/fastlane/Snapfile
  • ios/scripts/cloud-testflight.sh
  • ios/scripts/upload-testflight.sh
  • web/services/apns/routePolicy.ts
  • web/tests/apns.test.ts

Comment thread .github/workflows/ios-screenshots.yml
Comment thread .github/workflows/ios-screenshots.yml
Comment thread ios/cmuxUITests/SnapshotUITests.swift Outdated
Comment thread ios/cmuxUITests/SnapshotUITests.swift Outdated
Comment thread ios/scripts/upload-testflight.sh Outdated
Comment on lines +296 to +299
DISPLAY_NAME_ARGS=()
if [[ -n "$PRODUCT_DISPLAY_NAME_OVERRIDE" ]]; then
DISPLAY_NAME_ARGS=( "PRODUCT_DISPLAY_NAME=$PRODUCT_DISPLAY_NAME_OVERRIDE" )
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
sed -n '1,12p' ios/scripts/upload-testflight.sh   # shebang + set flags
rg -nP 'set -[a-z]*u|set -o nounset' ios/scripts/upload-testflight.sh
rg -nP '"\$\{DISPLAY_NAME_ARGS\[@\]\}"' ios/scripts/upload-testflight.sh
bash --version | head -1   # sandbox bash, for reference only

Repository: manaflow-ai/cmux

Length of output: 1044


🏁 Script executed:

#!/bin/bash
# Inspect the array initialization section
sed -n '269,300p' ios/scripts/upload-testflight.sh

# Inspect both archive call sites with context
sed -n '540,560p' ios/scripts/upload-testflight.sh
sed -n '560,580p' ios/scripts/upload-testflight.sh

# Check if PRODUCT_DISPLAY_NAME_OVERRIDE has a default value
rg -B2 -A2 'PRODUCT_DISPLAY_NAME_OVERRIDE' ios/scripts/upload-testflight.sh | head -40

Repository: manaflow-ai/cmux

Length of output: 3760


Empty DISPLAY_NAME_ARGS array expansion breaks under set -u on macOS system bash.

For the beta lane (the default), PRODUCT_DISPLAY_NAME_OVERRIDE remains empty, leaving DISPLAY_NAME_ARGS as an empty array. On bash 3.2 (macOS system /bin/bash), expanding "${DISPLAY_NAME_ARGS[@]}" under set -u raises unbound variable and breaks the most common path. Use the conditional-expansion guard to ensure empty array safety across bash versions.

Apply at both archive call sites (lines 549 & 572):

Fix
-      "${DISPLAY_NAME_ARGS[@]}" \
+      ${DISPLAY_NAME_ARGS[@]+"${DISPLAY_NAME_ARGS[@]}"} \
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/scripts/upload-testflight.sh` around lines 296 - 299, The empty
DISPLAY_NAME_ARGS array expansion breaks under set -u strict mode on macOS bash
3.2 when PRODUCT_DISPLAY_NAME_OVERRIDE is not set. Find the two archive call
sites that expand DISPLAY_NAME_ARGS (referenced at lines 549 and 572) where
"${DISPLAY_NAME_ARGS[@]}" is used, and apply the conditional expansion guard by
changing the expansion to use "${DISPLAY_NAME_ARGS[@]:-}" to safely handle the
empty array case across bash versions.

The resolve step exported SNAPSHOT_DEVICES to $GITHUB_ENV (for later steps) but
then echoed $SNAPSHOT_DEVICES, which is unset in the current shell, so set -u
aborted the step. Confirm by grepping the env file instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread ios/cmuxUITests/SnapshotUITests.swift
…iants

Resolver picked the device TYPE 'iPad Pro 13-inch (M5) (16GB)', which has no
pre-created simulator, so fastlane errored 'not in list of available
simulators'. Resolve against available simulator DEVICES and exclude RAM-variant
(GB) names; prefer iPhone NN Pro Max + iPad Pro/Air 13-inch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

Comment thread ios/scripts/cloud-testflight.sh
lawrencecchen and others added 2 commits June 23, 2026 05:42
The Snapfile's devices([...]) overrode the action's devices param, so CI's
runtime-resolved simulators were ignored and fastlane looked for the stale
'iPhone 16 Pro Max'. Move devices+languages to the Fastfile (env-overridable,
SNAPSHOT_DEVICES/SNAPSHOT_LANGUAGES) as the single source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CMUX_UITEST_MOCK_DATA alone lands on the add-device screen, so snapshots were
empty (0 images). Use the standalone preview hooks that render real UI with no
sign-in/pairing: WORKSPACE_LIST_PREVIEW + TERMINAL_PREVIEW (+ fake keyboard),
settling on window/foreground instead of identifiers the preview views do not
expose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread ios/cmuxUITests/SnapshotUITests.swift
Make the captured screenshots presentable for the App Store:
- TerminalLayoutPreviewView feeds a sample ANSI agent-session transcript when
  CMUX_UITEST_TERMINAL_PREVIEW_CONTENT=1, so the terminal shot shows real
  content instead of a blank surface (blank layout preview unchanged).
- SnapshotUITests enables that flag, drops the debug zoom overlay, and swipes
  away the one-time 'Ready for Apple Intelligence' notification banner.
- Fastfile capture uses override_status_bar for a clean 9:41 status bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread .github/workflows/ios-screenshots.yml

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
ios/cmuxUITests/SnapshotUITests.swift (1)

30-65: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Screenshot test cannot fail, so empty/blank captures pass CI silently.

settle() only does best-effort waits (_ = app.wait(...), _ = ...waitForExistence(...)) and the snapshot calls are unconditional. Combined with continueAfterFailure = true and no XCTAssert*, a launch that never renders the intended preview (e.g. preview env flag regressed, fixture broke) still produces a green run with a blank/launch-screen artifact. Anchor at least one stable element per screen and assert it before capturing.

Same underlying gap previously raised on the anchor checks; re-flagging since the refactor dropped the identifier waits without adding assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/cmuxUITests/SnapshotUITests.swift` around lines 30 - 65, The snapshot
tests in the `settle()` method and subsequent test flow use best-effort waits
that discard results and unconditional snapshot calls with no assertions,
allowing blank/unrendered screenshots to pass silently if preview environment
flags regress or fixtures break. Replace the best-effort waits in `settle()`
with assertions, or add explicit XCTAssert calls before each snapshot call to
anchor and validate at least one stable UI element exists on each screen before
capturing. Add assertions targeting a stable element for the workspace list
before snapshot("01-Workspaces"), a stable element for the terminal surface
before snapshot("02-Terminal"), and a stable element for the keyboard view
before snapshot("03-Terminal-Keyboard").
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@ios/cmuxUITests/SnapshotUITests.swift`:
- Around line 30-65: The snapshot tests in the `settle()` method and subsequent
test flow use best-effort waits that discard results and unconditional snapshot
calls with no assertions, allowing blank/unrendered screenshots to pass silently
if preview environment flags regress or fixtures break. Replace the best-effort
waits in `settle()` with assertions, or add explicit XCTAssert calls before each
snapshot call to anchor and validate at least one stable UI element exists on
each screen before capturing. Add assertions targeting a stable element for the
workspace list before snapshot("01-Workspaces"), a stable element for the
terminal surface before snapshot("02-Terminal"), and a stable element for the
keyboard view before snapshot("03-Terminal-Keyboard").

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5cdc41c4-9c8e-4f25-a98b-9c4dcc0f1ab6

📥 Commits

Reviewing files that changed from the base of the PR and between 651c241 and 914de8b.

📒 Files selected for processing (5)
  • .github/workflows/ios-screenshots.yml
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift
  • ios/cmuxUITests/SnapshotUITests.swift
  • ios/fastlane/Fastfile
  • ios/fastlane/Snapfile

updateUIView never re-ran with a non-zero size, so the sample transcript was
never fed and the terminal shot came out blank. Feed it from the surface's
first didResize (grid sized = can render). Also trigger the screenshots
workflow on preview-view changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift (1)

66-90: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Localize the screenshot transcript copy instead of hardcoding English.

Line 66 introduces user-visible terminal text as bare English literals, so Japanese screenshot runs will still render English content. Route these lines through localization keys and provide en/ja catalog entries.

Suggested direction
-    static let sampleTranscript: Data = {
+    static func sampleTranscript(locale: Locale = .current) -> Data {
         let esc = "\u{1B}"
         let reset = "\(esc)[0m"
@@
-        let lines = [
-            "\(dim)~/projects/app\(reset)  \(cyan)main\(reset)",
-            "\(prompt) claude \(dim)\"add a dark mode toggle\"\(reset)",
+        let commandText = String(
+            localized: "terminalPreview.sample.command",
+            defaultValue: "add a dark mode toggle"
+        )
+        let statusText = String(
+            localized: "terminalPreview.sample.status",
+            defaultValue: "I'll add a dark mode toggle to Settings."
+        )
+        let lines = [
+            "\(dim)~/projects/app\(reset)  \(cyan)main\(reset)",
+            "\(prompt) claude \(dim)\"\(commandText)\"\(reset)",
@@
-            "\(magenta)●\(reset) I'll add a dark mode toggle to Settings.",
+            "\(magenta)●\(reset) \(statusText)",
@@
-        return Data(lines.joined(separator: "\r\n").utf8)
-    }()
+        return Data(lines.joined(separator: "\r\n").utf8)
+    }
@@
-            surfaceView.processOutput(TerminalLayoutPreviewSurface.sampleTranscript)
+            surfaceView.processOutput(TerminalLayoutPreviewSurface.sampleTranscript())

As per coding guidelines, “All user-facing strings must be localized … currently English and Japanese,” and as per path instructions, full internationalization must be enforced for production user-facing text changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift`
around lines 66 - 90, The sampleTranscript static variable in
TerminalLayoutPreviewView contains hardcoded English user-visible strings that
must be localized per coding guidelines. Extract all user-facing text from the
lines array in the sampleTranscript computed property (such as "Reading
SettingsView.swift", "I'll add a dark mode toggle", "Build succeeded", etc.) and
replace them with NSLocalizedString references using appropriate localization
keys. Then create corresponding localization catalog entries providing both
English and Japanese translations for each extracted string.

Sources: Coding guidelines, Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift`:
- Around line 66-90: The sampleTranscript static variable in
TerminalLayoutPreviewView contains hardcoded English user-visible strings that
must be localized per coding guidelines. Extract all user-facing text from the
lines array in the sampleTranscript computed property (such as "Reading
SettingsView.swift", "I'll add a dark mode toggle", "Build succeeded", etc.) and
replace them with NSLocalizedString references using appropriate localization
keys. Then create corresponding localization catalog entries providing both
English and Japanese translations for each extracted string.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bf9d5464-64a6-4141-9426-dad2cc0d33da

📥 Commits

Reviewing files that changed from the base of the PR and between 914de8b and 0f928d5.

📒 Files selected for processing (2)
  • .github/workflows/ios-screenshots.yml
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift

Add zh-Hans, zh-Hant, ko, de, fr, es, pt-BR, it, ru, nl, tr, pl to all iOS
xcstrings (app, agent chat UI, InfoPlist permission strings) and the project
knownRegions, alongside the existing en + ja.

Translations are a machine-translation first pass (placeholders/format specifiers
preserved, brand/tech terms kept) and should get native review before public
release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Store the App Store listing copy (description, keywords, promotional text,
URLs; en-US also name/subtitle) for en-US + ja, zh-Hans, zh-Hant, ko, de-DE,
fr-FR, es-ES, pt-BR, it, ru, nl-NL, tr, pl. Applied to App Store Connect and
kept here so the listing is reproducible via fastlane deliver and the
machine-translated copy is reviewable before public release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ios/fastlane/metadata/it/description.txt`:
- Line 3: Replace the English loanword "input" with native Italian terminology
in the iOS App Store metadata description. On line 3, change "quando gli agenti
richiedono input" to use either "immissione" or "ingresso" as appropriate. On
line 7, apply the same replacement pattern where "invia input" appears, using
either "immissione" or alternatively "istruzioni" for the sending context.
Ensure consistency in terminology choice across both occurrences for proper
localization.

In `@ios/fastlane/metadata/pl/description.txt`:
- Around line 1-11: In the Polish description text, the phrase "pierścienie
powiadomień gdy agent czeka na dane wejściowe" is missing a required comma
before the conjunction "gdy". Add a comma between "powiadomień" and "gdy" so it
reads "pierścienie powiadomień, gdy agent czeka na dane wejściowe" to comply
with Polish grammar rules.

In `@ios/fastlane/metadata/pt-BR/description.txt`:
- Line 9: In the Portuguese description text for the bullet point about secure
pairing, add a comma before the word "ou" to improve grammatical clarity and
follow Portuguese style conventions. Locate the line containing "Pareamento
seguro: conecte ao seu próprio Mac pela rede local ou por relay" and insert a
comma after "local" so it reads "rede local, ou por relay". This follows
standard Portuguese grammar when joining alternatives with "ou".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f66234ab-df93-466c-a1c1-ed7850e0f983

📥 Commits

Reviewing files that changed from the base of the PR and between 98d58b5 and a5342f9.

📒 Files selected for processing (72)
  • ios/fastlane/metadata/de-DE/description.txt
  • ios/fastlane/metadata/de-DE/keywords.txt
  • ios/fastlane/metadata/de-DE/marketing_url.txt
  • ios/fastlane/metadata/de-DE/promotional_text.txt
  • ios/fastlane/metadata/de-DE/support_url.txt
  • ios/fastlane/metadata/en-US/description.txt
  • ios/fastlane/metadata/en-US/keywords.txt
  • ios/fastlane/metadata/en-US/marketing_url.txt
  • ios/fastlane/metadata/en-US/name.txt
  • ios/fastlane/metadata/en-US/promotional_text.txt
  • ios/fastlane/metadata/en-US/subtitle.txt
  • ios/fastlane/metadata/en-US/support_url.txt
  • ios/fastlane/metadata/es-ES/description.txt
  • ios/fastlane/metadata/es-ES/keywords.txt
  • ios/fastlane/metadata/es-ES/marketing_url.txt
  • ios/fastlane/metadata/es-ES/promotional_text.txt
  • ios/fastlane/metadata/es-ES/support_url.txt
  • ios/fastlane/metadata/fr-FR/description.txt
  • ios/fastlane/metadata/fr-FR/keywords.txt
  • ios/fastlane/metadata/fr-FR/marketing_url.txt
  • ios/fastlane/metadata/fr-FR/promotional_text.txt
  • ios/fastlane/metadata/fr-FR/support_url.txt
  • ios/fastlane/metadata/it/description.txt
  • ios/fastlane/metadata/it/keywords.txt
  • ios/fastlane/metadata/it/marketing_url.txt
  • ios/fastlane/metadata/it/promotional_text.txt
  • ios/fastlane/metadata/it/support_url.txt
  • ios/fastlane/metadata/ja/description.txt
  • ios/fastlane/metadata/ja/keywords.txt
  • ios/fastlane/metadata/ja/marketing_url.txt
  • ios/fastlane/metadata/ja/promotional_text.txt
  • ios/fastlane/metadata/ja/support_url.txt
  • ios/fastlane/metadata/ko/description.txt
  • ios/fastlane/metadata/ko/keywords.txt
  • ios/fastlane/metadata/ko/marketing_url.txt
  • ios/fastlane/metadata/ko/promotional_text.txt
  • ios/fastlane/metadata/ko/support_url.txt
  • ios/fastlane/metadata/nl-NL/description.txt
  • ios/fastlane/metadata/nl-NL/keywords.txt
  • ios/fastlane/metadata/nl-NL/marketing_url.txt
  • ios/fastlane/metadata/nl-NL/promotional_text.txt
  • ios/fastlane/metadata/nl-NL/support_url.txt
  • ios/fastlane/metadata/pl/description.txt
  • ios/fastlane/metadata/pl/keywords.txt
  • ios/fastlane/metadata/pl/marketing_url.txt
  • ios/fastlane/metadata/pl/promotional_text.txt
  • ios/fastlane/metadata/pl/support_url.txt
  • ios/fastlane/metadata/pt-BR/description.txt
  • ios/fastlane/metadata/pt-BR/keywords.txt
  • ios/fastlane/metadata/pt-BR/marketing_url.txt
  • ios/fastlane/metadata/pt-BR/promotional_text.txt
  • ios/fastlane/metadata/pt-BR/support_url.txt
  • ios/fastlane/metadata/ru/description.txt
  • ios/fastlane/metadata/ru/keywords.txt
  • ios/fastlane/metadata/ru/marketing_url.txt
  • ios/fastlane/metadata/ru/promotional_text.txt
  • ios/fastlane/metadata/ru/support_url.txt
  • ios/fastlane/metadata/tr/description.txt
  • ios/fastlane/metadata/tr/keywords.txt
  • ios/fastlane/metadata/tr/marketing_url.txt
  • ios/fastlane/metadata/tr/promotional_text.txt
  • ios/fastlane/metadata/tr/support_url.txt
  • ios/fastlane/metadata/zh-Hans/description.txt
  • ios/fastlane/metadata/zh-Hans/keywords.txt
  • ios/fastlane/metadata/zh-Hans/marketing_url.txt
  • ios/fastlane/metadata/zh-Hans/promotional_text.txt
  • ios/fastlane/metadata/zh-Hans/support_url.txt
  • ios/fastlane/metadata/zh-Hant/description.txt
  • ios/fastlane/metadata/zh-Hant/keywords.txt
  • ios/fastlane/metadata/zh-Hant/marketing_url.txt
  • ios/fastlane/metadata/zh-Hant/promotional_text.txt
  • ios/fastlane/metadata/zh-Hant/support_url.txt

@@ -0,0 +1,11 @@
cmux porta i tuoi agenti di programmazione sul telefono. Collega il tuo Mac con il terminale cmux e guarda i tuoi agenti AI lavorare in tempo reale, ricevi una notifica push nel momento in cui un agente ha bisogno della tua attenzione o completa un'attività, e rispondi o esegui comandi da qualsiasi luogo.

cmux è il terminale costruito per gli agenti di programmazione: schede verticali, anelli di notifica quando gli agenti richiedono input, pannelli suddivisi, un browser integrato e una CLI programmabile sul desktop. L'app iOS è il tuo telecomando per tutto questo.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use native Italian term instead of English loanword "input".

Lines 3 and 7 use the English loanword "input" in Italian App Store metadata. For formal App Store listings and localization completeness, replace with a native Italian term: "ingresso" (entry/input) or "immissione" (input/entry).

Suggested replacements:

  • Line 3: ...quando gli agenti richiedono input... → ...quando gli agenti richiedono immissione... or ...ingresso...
  • Line 7: ...invia input, esegui comandi... → ...invia immissioni, esegui comandi... or ...invia istruzioni...

Also applies to: 7-7

🧰 Tools
🪛 LanguageTool

[uncategorized] ~3-~3: "ingresso" "entrata" "immissione"
Context: ...i notifica quando gli agenti richiedono input, pannelli suddivisi, un browser integra...

(ST_01_005)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/fastlane/metadata/it/description.txt` at line 3, Replace the English
loanword "input" with native Italian terminology in the iOS App Store metadata
description. On line 3, change "quando gli agenti richiedono input" to use
either "immissione" or "ingresso" as appropriate. On line 7, apply the same
replacement pattern where "invia input" appears, using either "immissione" or
alternatively "istruzioni" for the sending context. Ensure consistency in
terminology choice across both occurrences for proper localization.

Source: Linters/SAST tools

Comment thread ios/fastlane/metadata/pl/description.txt
Comment thread ios/fastlane/metadata/pt-BR/description.txt
lawrencecchen and others added 3 commits June 23, 2026 17:33
…ons, frameit)

Make App Store screenshots realistic and on-message:
- TerminalPreviewTranscripts: Claude Code / Codex / OpenCode / pi sample sessions,
  selected via CMUX_UITEST_TERMINAL_TRANSCRIPT.
- ScreenshotKeyboardView: drawn dark iOS keyboard overlaid in the reserved
  keyboard region (CMUX_UITEST_SCREENSHOT_KEYBOARD=1); the simulator won't render
  the system keyboard in CI. Device-aware height (iPhone vs iPad).
- ScreenshotNotificationBanner: iOS push banner over the workspace list
  (CMUX_UITEST_NOTIFICATION_BANNER=1) to show agent notifications.
- SnapshotUITests: 7 screens (workspaces, notifications, 4 agents w/ keyboard,
  full Ghostty terminal).
- frameit pipeline: tranquil gradient background, Framefile.json, localized
  title.strings (prepare_frames.py from titles.*.json), framed in the
   lane after capture. Workflow installs imagemagick; deliver uploads
  the framed images. Dynamic island comes from the frameit device frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-lane

# Conflicts:
#	ios/cmux/Resources/Localizable.xcstrings
05-Opencode said 'OpenCode, pi, any agent' but pi is the very next screenshot
(06-Pi). Reworded to 'OpenCode and any agent' (and each locale's equivalent) so
the two shots don't overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d-lane

# Conflicts:
#	Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings
#	Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
#	ios/scripts/upload-testflight.sh

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3ed49c8. Configure here.

Comment thread .github/workflows/ios-streamed-validate.yml Outdated
…d-lane

# Conflicts:
#	Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings
@cursor

cursor Bot commented Jul 14, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@lawrencecchen
lawrencecchen merged commit 914a5de into main Jul 14, 2026
9 checks passed
@lawrencecchen
lawrencecchen deleted the feat-ios-appstore-prod-lane branch July 14, 2026 22:22
@lawrencecchen
lawrencecchen restored the feat-ios-appstore-prod-lane branch July 18, 2026 10:18
austinywang added a commit that referenced this pull request Sep 10, 2026
…issions guard, screenshot decoupling, notarization hardening) (#12157)

* ci: guard reusable-workflow permission grants (red on main's shape)

GitHub validates a reusable workflow's permissions against the calling job
when it parses the caller. A callee that requests a scope the caller does
not grant fails the whole caller run at startup, before any job runs. That
is what blocks the stable release today: release.yml calls
ios-screenshots.yml, which requests `actions: write` while release.yml
grants none (#12149).

Add scripts/ci/check_reusable_workflow_permissions.py (python3 stdlib
only) that walks every local `uses: ./.github/workflows/*.yml` call,
computes the calling job's grant (job block, else workflow block, else the
repository default) and the callee's request (max over its workflow block
and every job block, gated jobs included, mirroring 4b9720d), follows
nested calls with the intermediate grant, and fails on any scope that asks
for more. tests/test_ci_reusable_workflow_permissions.py covers the rule on
fixture trees (the exact #12149 shape, shorthands, job-level overrides,
repository defaults, nesting, missing callees) and then runs the checker on
the real tree, which fails until the next commit fixes the workflows.

Wired into the workflow-guard-tests job in ci.yml.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: stop ios-screenshots.yml requesting actions: write (fixes release startup)

The screenshot workflow declared `actions: write` since #6697, but no step
ever used it: checkout runs with persist-credentials disabled, the two
artifact uploads use the runner's artifact token, the capture is a DEBUG
simulator build, and the App Store Connect upload path authenticates with an
API key. When #11342 made release.yml call this workflow, GitHub compared the
callee's block with the caller's grant (contents/attestations/id-token only)
and refused the release workflow at parse time: startup_failure, no job run,
for tag pushes and dispatches alike (#12149).

Reduce the callee to `contents: read`, the minimum its steps use. Widening
release.yml instead would have handed a UI-test job the ability to cancel or
dispatch runs for no benefit. The guard added in the previous commit now
passes on the tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: do not gate build-sign-notarize on iOS screenshot capture

#11342 made build-sign-notarize need generate-ios-screenshots ("gates
build-sign-notarize on screenshot success"). The DMG never consumes those
artifacts: nothing in build-sign-notarize downloads them, and the App Store
tooling (ios/scripts/appstore-shots.sh capture) dispatches its own
ios-screenshots.yml run rather than reading a release run. What the gate
did do was make every stable macOS release wait for, and fail with, a
300-minute simulator capture across nine locales on shared macOS runners,
a lane that had "not compiled on main for days" before #11342 healed it.

Keep the capture in release.yml as a sibling job, so every tag still gets
screenshots at the exact release ref and a failed capture still turns the
run red, but drop it from build-sign-notarize.needs. Trade-off: a green
macOS release no longer implies the screenshot capture succeeded; the run
conclusion still does.

tests/test_ci_release_ios_screenshots_decoupled.sh pins the policy (fails
on main's needs list, passes here) and runs in workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: give the screenshot capture job only contents: read and no secrets

The screenshot job runs a DEBUG simulator UI test after `brew install`
of fastlane and imagemagick. Capture-only needs to read the repository and
nothing else: checkout runs with persist-credentials disabled, artifact
uploads use the runner's artifact token, and the App Store Connect upload
path in ios-screenshots.yml is gated to workflow_dispatch from main, so it
is unreachable from a release run whatever `upload` says.

Set job-level `permissions: contents: read` on the calling job (the pattern
the cmux-tui callers already use) instead of passing the workflow's
contents/attestations/id-token write grant through, and drop
`secrets: inherit`, which handed every repository secret (Developer ID
certificate and password, notarization credentials, Sparkle private key,
R2 keys, Sentry token, ASC key) to that job for no benefit.

Trade-off: if the release lane ever wants the ASC upload, it must add
`secrets: inherit` back together with `upload: true` and relax the callee's
dispatch-only guard. That should be a deliberate change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: let the Sparkle monotonic guard warn on non-tag dry runs

release.yml runs tests/test_ci_sparkle_build_monotonic.sh at the top of
build-sign-notarize. On plain main it fails (CURRENT_PROJECT_VERSION 102
equals the published 0.64.22 build), which is correct for a tag push about
to publish but wrong for the workflow's built-in dry run: a non-tag
workflow_dispatch publishes nothing and, by design, runs from a branch
that has not been bumped yet. The dry run was therefore impossible without
a throwaway bump commit.

Chosen fix: a CMUX_SPARKLE_MONOTONIC_MODE switch on the guard, `enforce`
by default (tag pushes, scripts/release-pretag-guard.sh) and `warn` when
release.yml runs from anything but refs/tags/*. Rejected alternative:
running the dry run from a throwaway branch with a temporary bump, which
would validate a commit that never merges and leave the pipeline
un-dry-runnable for everyone else.

tests/test_sparkle_build_monotonic_modes.sh drives the guard against
fixture project files and a local appcast (stale fails in enforce and by
default, warns in warn mode, bumped passes in both, unreachable appcast
soft-passes, unknown mode fails) and pins the ref-based selection in
release.yml. Wired into workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: give Gatekeeper twenty minutes to see a fresh notarization ticket

scripts/ci/notarize-computer-use-helper.sh polls `spctl` on the standalone
Computer Use helper after stapling because Apple's CDN publishes the ticket
some time after notarytool reports Accepted. The budget was 20 x 15s. Nightly
run 34208928547 (2026-09-08) exhausted it: Accepted at 09:51:28, still
"Unnotarized Developer ID" at 09:56:18, exit 3, whole universal lane failed,
while the arm64 and x86_64 lanes passed in the same window.

Raise the default to 80 x 15s (twenty minutes) and announce the budget on the
first rejection so a log reader can tell propagation from a hang. Trade-off:
a genuinely rejected helper now takes up to twenty minutes to fail instead
of five, which only delays an already-lost release; a short budget failed
good releases, each costing a full rebuild and a human retry. Both knobs
remain env-configurable (CMUX_GATEKEEPER_ASSESS_ATTEMPTS/_DELAY_SECONDS).
nightly's signing job has an 80-minute timeout with a 7-10 minute typical
duration, so the budget fits there; release.yml's timeout is raised in the
next commit.

tests/test_notarize_computer_use_helper.sh now pins the defaults (at least
1200s, polled at least every 30s, env-configurable literals) and the budget
announcement, alongside the existing override and give-up coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: raise build-sign-notarize timeout to 90 minutes

v0.64.22's build-sign-notarize took 39.8 minutes on 2026-08-03. Since then
the job gained the Cloud tunnel system extension and its Go engine build
(#11789), the universal diff sidecar and cmux-tui client install (#12006),
two extra smoke launches, and a Gatekeeper propagation wait that can now
run twenty minutes on its own. A 60-minute ceiling leaves no room for a
slow notarytool day, and a timeout mid-notarization wastes the whole build.

90 minutes covers the measured baseline plus the known variable waits with
headroom while still bounding a hung job on a shared self-hosted runner. To
be re-checked against the dry-run duration for this branch: the timeout must
stay at least 25 percent above it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: name the tunnel extension by its bundle identifier, not its App ID

The first release dry run that could start after the permission fix
(run 34222835589) failed 30 minutes in, at "Verify binary architectures":

  error: system extension identifier is 'com.cmuxterm.app.tunnel',
         expected '7WLXT3NR37.com.cmuxterm.app.tunnel'

#11789 passed the team-prefixed App ID to
scripts/normalize-system-extension-bundle.sh and looked for the tunnel
binary under 7WLXT3NR37.com.cmuxterm.app.tunnel.systemextension. The
Release build's PRODUCT_BUNDLE_IDENTIFIER for the extension is
com.cmuxterm.app.tunnel; only NEMachServiceName
($(CMUX_TEAM_ID_PREFIX)$(PRODUCT_BUNDLE_IDENTIFIER)) and the provisioning
profile's com.apple.application-identifier carry the team prefix, and the
app activates whatever CFBundleIdentifier the bundled extension declares.
nightly.yml already does it this way and ships
com.cmuxterm.app.nightly.tunnel.systemextension with a profile for
7WLXT3NR37.com.cmuxterm.app.nightly.tunnel (run 34220568401). The mistake
was invisible until now because release.yml could not start at all.

Use the bundle identifier for the normalize call and the directory the
verify step inspects; keep the App ID for the profile check.
tests/test_ci_release_tunnel_identifiers.sh derives all three from
cmux.xcodeproj/project.pbxproj (fails on main's release.yml, passes here)
and runs in workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* Fix main's package-test compile error and Swift warning-budget violations

main is red for every branch that routes the macOS lane (#12161, #12165),
which keeps ci-status from ever reporting green on this release-pipeline
PR. Fix both at the root rather than refreshing the budget:

- swift-package-tests: FakeTerminalEngine.swift gained a `UUID` parameter
  in #10564 but imports only GhosttyKit. Add `import Foundation`.
- tests-build-and-lag (scripts/swift_warning_budget.py, actual > budget):
  * AppDelegate+PaneMemoryGuardrail.swift: parenthesize the two
    `compactMap` closures inside the `guard` condition ("trailing closure
    in this context is confusable with the body of the statement").
  * SessionIndexTableController.swift: the bounds-change observer block is
    typed @sendable in the current SDK, so referencing `isApplyingRows` and
    `reconcilePresentation(in:)` warned. The block is delivered on
    `queue: .main`, so run it under `MainActor.assumeIsolated`, the same
    pattern SidebarWorkspaceRowCellView uses; no async hop, same timing.
  * CmuxTuiSnapshotParser.swift: `switch resourceID.kind` already covers
    every SurfaceResourceKind case (terminal, display, browser), so the
    `default: continue` could never run. Remove it; a new case now fails
    to compile here instead of being silently skipped.
  * SurfaceCatalogModel.swift: `if let rowID,` rebound a value the body
    never read; test `rowID != nil` instead.
  * TerminalController.swift: `payload` in the `.delivered` branch is never
    mutated; make it `let`.

Every change is behavior-preserving. Verified with `swiftc -parse` on each
file locally (no app build on the shared machine); the routed CI lane
proves the build and the budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* Normalize project.pbxproj (main bypassed the pre-commit hook in #12145)

scripts/check-pbxproj.sh fails on main since 567ba48 (#12145): the
three StackAccountAvatarViewTests.swift entries were added out of the
normalizer's sorted order, so every PR's workflow-guard-tests job goes
red at "Validate pbxproj objectVersion pin and normalization" and
linux-preflight, tests and ci-status cascade from it. This is the output
of scripts/normalize-pbxproj.py: three lines reordered, no identifier or
setting changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* tests: drive sparkle_generate_appcast.sh through the no-delta release path (red)

Release dry run 34227505375 (2026-09-08) reported "Generate Sparkle
appcast: success" and uploaded a cmux-release-dry-run artifact containing
only the DMG. The job log shows why:

  ./scripts/sparkle_generate_appcast.sh: line 93: delta_args[@]: unbound variable

A tag push would have published a GitHub Release without appcast.xml,
so no Sparkle client would ever be offered the update, and the R2 stable
appcast upload would then fail after the release already existed.

tests/test_sparkle_generate_appcast_no_deltas.sh runs the real script
with fake git/xcodebuild/generate_appcast/sign_update tools under every
bash on the machine (/bin/bash 3.2 on macOS reproduces the bug; bash 5
never did) and requires a signed appcast at the requested output path
with no delta arguments when there are no previous archives, and with
--maximum-deltas when there are. It also requires release.yml to verify
the feed after generation instead of trusting the exit status. Fails on
main's script and workflow; the next commit fixes both. Wired into
workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: generate the appcast when there are no previous archives (bash 3.2)

#11788 added `delta_args=()` and passed "${delta_args[@]}" to
generate_appcast. In bash 4.4+ an empty array expands to nothing; in
bash 3.2 (macOS /bin/bash, which `#!/usr/bin/env bash` resolves to on
the release runner) it is an "unbound variable" error under `set -u`.
Worse, with the script's EXIT trap bash 3.2 then exits 0, so the step
passed and no appcast was written. Nightly always has previous archives
(delta_args non-empty) and was never affected; the stable release lane
never has them and has been broken since 2026-09-03, unnoticed because
release.yml could not start at all (#12149).

Expand the array as ${delta_args[@]+"${delta_args[@]}"}, which is empty
when the array is empty in every bash. In release.yml, verify after
generation that appcast.xml exists, carries sparkle:edSignature and
references cmux-macos.dmg before anything uploads it: the exit status
alone is not a reliable signal on bash 3.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: fail the Sparkle monotonic guard closed when the appcast is unreachable

CodeRabbit on #12157: enforce mode (tag pushes, release-pretag-guard.sh)
soft-passed when the published appcast could not be fetched, so a tag
push could publish a stale CURRENT_PROJECT_VERSION on a network blip or
on a latest release that lacks appcast.xml, the exact state that leaves
Sparkle clients without updates. A missing signal must fail closed when
the run is about to publish.

enforce mode now fails with an explanation when the published build is
unknown; warn mode (non-tag dry runs) keeps the soft pass because it
publishes nothing. curl retries transient failures (3 x 2s by default,
overridable so the tests exercise the unreachable path without waiting).
tests/test_sparkle_build_monotonic_modes.sh covers enforce, default and
warn against an unreachable appcast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* tests: assert the journal-carried pane clear that #11976 replaced clear_notifications with

#11976 removed the v1 `clear_notifications --tab --panel` send from the
Claude prompt-submit and pre-tool-use hooks; the pane-scoped clear now
rides on the `agent.turn.started` / `agent.state.changed` journal events,
which the app reconciles into `clearNotifications(forTabId:surfaceId:)`.
It updated the Python hook tests to the new wire contract but not
ClaudeHookLifecycleCleanupTests, whose two moved-pane tests still expected
the removed command. They fail on main in the strict app-host
agent-notification step (shard 6), unnoticed because #11976's PR CI never
routed the macOS lane.

Assert the new contract instead: the journal event for the hook names the
re-homed workspace and the live pane (via the existing
AgentJournalAppendCapture parser), and nothing still wipes the whole
destination workspace. SessionEnd keeps sending the v1 command, so its
tests are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: make app-host hangs fail in minutes instead of the 75-minute job timeout

Every macOS lane run since 2026-09-03 has ended with app-host shards
"cancelled" at the 75-minute job timeout. Today's logs (run 34236235360,
shards 1/2/4, both attempts) show the mechanism, and it is two plumbing
defects rather than the tests:

1. scripts/ci/xcodebuild_noninteractive.py resets its 300s idle deadline on
   every output chunk. Since #11755 (merged 2026-09-03T02:09Z, after the
   last green lane at 2026-09-02T09:21Z) the app host logs every Cloud API
   poll, `[CloudVM] GET /api/vm not_signed_in`, every 45 seconds. A test
   host hung inside a WebKit page load (WebContent XPC: "Could not signal
   service ... 113") therefore never looks idle, so the wrapper's kill and
   retry path, which handled the same WebKit failure on the 09-02 green
   run, never fires.
2. The tolerant batch watchdog in ci.yml (1800s) killed only the
   console-session launcher and left the lock wrapper, xcodebuild and the
   app host alive; the app host kept the `| tee` pipe open, so the step sat
   idle from "timeout after 1800s; terminating" until the job timeout.

Fixes:
- CMUX_XCODEBUILD_NONINTERACTIVE_IDLE_IGNORE_RE: output lines matching it
  do not count as progress. run-app-host-xcodebuild.sh defaults it to the
  Cloud poll line (an empty value restores counting everything; an
  invalid regex fails closed with exit 2). Real output still resets the
  clock, so a slow but progressing batch is unaffected.
- The ci.yml batch runner writes xcodebuild output to the capture file and
  streams it with a detached tail, kills the whole process tree
  (pgrep -P recursion, TERM then KILL) when the batch budget expires, and
  reads both the streamed and per-batch captures for the SwiftPM retry
  heuristic.

Behavior tests: tests/test_ci_xcodebuild_noninteractive_helper.py drives a
child that prints only the keepalive every 50ms (finishes without the
pattern, idles out at 0.3s with it, invalid pattern exits 2);
tests/test_ci_change_areas.py runs the real step script against a runner
that hangs and leaves a grandchild holding stdout, and requires exit 124
within seconds with the grandchild dead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: keep the canonical OUTPUT capture line the SPM-retry guard pins

tests/test_ci_unit_test_spm_retry.sh requires `OUTPUT=$(cat "$TEST_OUTPUT")`
verbatim in the app-host step; the previous commit folded the per-batch
capture files into that line and turned workflow-guard-tests red. Keep the
pinned line and append the per-batch captures on the next line instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: keep a watchdog-killed app-host batch terminal; drop the wall-clock assert

CodeRabbit on #12157: the expected-failure normalization in
run_unit_test_batch greps the capture for the last "Executed ... failures"
summary and returns success on "(0 unexpected)". After the watchdog kills a
batch (status 124) the capture can still hold an earlier attempt's summary
(run-app-host-xcodebuild.sh retries into the same file), so a terminated
batch could be reported as passed. Treat 124 as terminal before the
normalization. The hung-runner behavior test now prints a decoy
"(0 unexpected)" summary before hanging and requires the step to stay at
124 without the "All failures ... are expected" message.

Also drop the `elapsed < 60` assertion from that test: the harness's 120s
subprocess timeout already bounds a runaway step, and a hard wall-clock
ceiling only adds scheduler-delay flakes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* chore: normalize project file after main merge

* test: remove timing dependency from terminal lane test

* ci: accept completed app-host summaries after launcher timeout

* test: make idle watchdog coverage scheduler tolerant

* ci: require Swift Testing completion before accepting launcher timeout

* ci: fail fast on known broad app-host hangs

* test: allowlist virtual retry delay fixtures

* ci: preserve app-host lock queue headroom

* ci: restore terminal creation CLI regression coverage

* ci: retain release guard coverage after main merge

* ci: remove obsolete app-host idle override

* cmuxTests: run the Coderouter no-socket tests without an unwaited expectation

`runCoderouterCLI(waitForSocket: false)` still asked `startMockServer` for a
case-bound `expectation(description: "cli mock socket handled")` and then
never waited on it. The shared accept loop fulfills that expectation when
the listener closes at the end of the helper, so XCTest ended
`testCoderouterUnknownVerbStillPassesThroughToTheInstalledCLI` and
`testCoderouterClaudeAddOAuthTokenRejectsAPIKeyShapeBeforeTheSocket` with
"Failed due to unwaited expectation", which it counts as an *unexpected*
failure. Since #12207 the app-host batch classifier fails a batch on any
unexpected failure, so this one test turned shard 5 (and the sibling test
shard 6) red on main and on every PR: run 34401456032, main run
34342638735 attempts 1 and 2.

Serve those two tests from the detached mock server instead, which owns no
expectation, and keep the waited path for every other Coderouter test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: rerun a remote tmux mirror suite once after an app-host crash

The non-tolerant "Run remote tmux mirror detach and placement regressions"
gate on shard 6 fails whenever the app host crashes mid-suite, which
#9348 documents as
nondeterministic: the crash point moves between tests and the relaunched
host passes the rest (run 34401456032 crashed in
dedicatedWindowSocketDefaultsToFocusNeutral; the previous run and both
main attempts passed the same step).

Capture each suite's output and rerun the suite exactly once, only when
xcodebuild printed "Restarting after unexpected exit, crash, or test
timeout". An assertion failure never earns a rerun and a second crash
still fails the shard, so the gate keeps rejecting real regressions.

tests/test_ci_change_areas.py drives the real step script against a fake
console runner: crash-then-pass is green with three invocations, an
assertion failure exits 65 after one invocation, and two crashes exit 65
after two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(iroh): promote the replacement connection deterministically

usableConnectionRetiresOlderConnectionsFromSameEndpointIdentity keyed the
markUsable call off `recorder.recordedCount() == 2`, which both handlers
evaluate concurrently. When the first connection's handler reached that
check after the replacement had already recorded, it promoted `first`
instead, superseded the freshly admitted replacement, and the replacement's
own markUsable returned false: "Expectation failed: await
admission.markUsable()" at CmxIrohEndpointServerTests.swift:353 in CI run
34414741413 (swift-package-tests), while the previous run passed the same
code.

Admit before recording so the test's `recorder.next()` proves `first` is
active before `replacement` is enqueued, and promote only the replacement
by identity. The suite passes three consecutive local runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* cmuxTests: serialize the stdin pump suite and bound its blocking waits

Shard 3 of CI run 34414741413 hung three times at the app-host wrapper's
300s idle timeout in the same batch. The hang sample shows
SSHPTYAttachReconnectInputFilterTests.stdinPumpFiltersReadyInputBeforeStopSignal
parked in stopFiltering() -> read() on the stop-acknowledgement pipe with
every other visible cooperative-pool thread also inside a test body's
synchronous wait. The pump under test is a detached task that needs one of
those same threads, and the five pump tests each hold a thread for the
~13s reconnect probe deadline while running concurrently, so the suite can
leave no thread for any pump (the family issue #12180 tracks).

Run the suite serialized so at most one test parks a thread at a time, and
bound every wait: stopFiltering now takes a 30s acknowledgement timeout and
must succeed, and the EOF/exact reads poll with the same deadline. A pump
that never gets scheduled now fails its test inside the batch instead of
parking the shard until the idle timeout retries are exhausted.

The two assertions in this suite that already fail on main
(readUntilEOF == forwardedInput in stdinPumpFiltersReadyInputBeforeStopSignal
and stdinPumpKeepsFilteringLateProbeRepliesAfterInitialDrain) are
unchanged; the batch classifier tolerates them today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
aerickson pushed a commit to aerickson/cmux that referenced this pull request Sep 13, 2026
…rkflow permissions guard, screenshot decoupling, notarization hardening) (manaflow-ai#12157)

* ci: guard reusable-workflow permission grants (red on main's shape)

GitHub validates a reusable workflow's permissions against the calling job
when it parses the caller. A callee that requests a scope the caller does
not grant fails the whole caller run at startup, before any job runs. That
is what blocks the stable release today: release.yml calls
ios-screenshots.yml, which requests `actions: write` while release.yml
grants none (manaflow-ai#12149).

Add scripts/ci/check_reusable_workflow_permissions.py (python3 stdlib
only) that walks every local `uses: ./.github/workflows/*.yml` call,
computes the calling job's grant (job block, else workflow block, else the
repository default) and the callee's request (max over its workflow block
and every job block, gated jobs included, mirroring 4b9720d), follows
nested calls with the intermediate grant, and fails on any scope that asks
for more. tests/test_ci_reusable_workflow_permissions.py covers the rule on
fixture trees (the exact manaflow-ai#12149 shape, shorthands, job-level overrides,
repository defaults, nesting, missing callees) and then runs the checker on
the real tree, which fails until the next commit fixes the workflows.

Wired into the workflow-guard-tests job in ci.yml.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: stop ios-screenshots.yml requesting actions: write (fixes release startup)

The screenshot workflow declared `actions: write` since manaflow-ai#6697, but no step
ever used it: checkout runs with persist-credentials disabled, the two
artifact uploads use the runner's artifact token, the capture is a DEBUG
simulator build, and the App Store Connect upload path authenticates with an
API key. When manaflow-ai#11342 made release.yml call this workflow, GitHub compared the
callee's block with the caller's grant (contents/attestations/id-token only)
and refused the release workflow at parse time: startup_failure, no job run,
for tag pushes and dispatches alike (manaflow-ai#12149).

Reduce the callee to `contents: read`, the minimum its steps use. Widening
release.yml instead would have handed a UI-test job the ability to cancel or
dispatch runs for no benefit. The guard added in the previous commit now
passes on the tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: do not gate build-sign-notarize on iOS screenshot capture

manaflow-ai#11342 made build-sign-notarize need generate-ios-screenshots ("gates
build-sign-notarize on screenshot success"). The DMG never consumes those
artifacts: nothing in build-sign-notarize downloads them, and the App Store
tooling (ios/scripts/appstore-shots.sh capture) dispatches its own
ios-screenshots.yml run rather than reading a release run. What the gate
did do was make every stable macOS release wait for, and fail with, a
300-minute simulator capture across nine locales on shared macOS runners,
a lane that had "not compiled on main for days" before manaflow-ai#11342 healed it.

Keep the capture in release.yml as a sibling job, so every tag still gets
screenshots at the exact release ref and a failed capture still turns the
run red, but drop it from build-sign-notarize.needs. Trade-off: a green
macOS release no longer implies the screenshot capture succeeded; the run
conclusion still does.

tests/test_ci_release_ios_screenshots_decoupled.sh pins the policy (fails
on main's needs list, passes here) and runs in workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: give the screenshot capture job only contents: read and no secrets

The screenshot job runs a DEBUG simulator UI test after `brew install`
of fastlane and imagemagick. Capture-only needs to read the repository and
nothing else: checkout runs with persist-credentials disabled, artifact
uploads use the runner's artifact token, and the App Store Connect upload
path in ios-screenshots.yml is gated to workflow_dispatch from main, so it
is unreachable from a release run whatever `upload` says.

Set job-level `permissions: contents: read` on the calling job (the pattern
the cmux-tui callers already use) instead of passing the workflow's
contents/attestations/id-token write grant through, and drop
`secrets: inherit`, which handed every repository secret (Developer ID
certificate and password, notarization credentials, Sparkle private key,
R2 keys, Sentry token, ASC key) to that job for no benefit.

Trade-off: if the release lane ever wants the ASC upload, it must add
`secrets: inherit` back together with `upload: true` and relax the callee's
dispatch-only guard. That should be a deliberate change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: let the Sparkle monotonic guard warn on non-tag dry runs

release.yml runs tests/test_ci_sparkle_build_monotonic.sh at the top of
build-sign-notarize. On plain main it fails (CURRENT_PROJECT_VERSION 102
equals the published 0.64.22 build), which is correct for a tag push about
to publish but wrong for the workflow's built-in dry run: a non-tag
workflow_dispatch publishes nothing and, by design, runs from a branch
that has not been bumped yet. The dry run was therefore impossible without
a throwaway bump commit.

Chosen fix: a CMUX_SPARKLE_MONOTONIC_MODE switch on the guard, `enforce`
by default (tag pushes, scripts/release-pretag-guard.sh) and `warn` when
release.yml runs from anything but refs/tags/*. Rejected alternative:
running the dry run from a throwaway branch with a temporary bump, which
would validate a commit that never merges and leave the pipeline
un-dry-runnable for everyone else.

tests/test_sparkle_build_monotonic_modes.sh drives the guard against
fixture project files and a local appcast (stale fails in enforce and by
default, warns in warn mode, bumped passes in both, unreachable appcast
soft-passes, unknown mode fails) and pins the ref-based selection in
release.yml. Wired into workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: give Gatekeeper twenty minutes to see a fresh notarization ticket

scripts/ci/notarize-computer-use-helper.sh polls `spctl` on the standalone
Computer Use helper after stapling because Apple's CDN publishes the ticket
some time after notarytool reports Accepted. The budget was 20 x 15s. Nightly
run 34208928547 (2026-09-08) exhausted it: Accepted at 09:51:28, still
"Unnotarized Developer ID" at 09:56:18, exit 3, whole universal lane failed,
while the arm64 and x86_64 lanes passed in the same window.

Raise the default to 80 x 15s (twenty minutes) and announce the budget on the
first rejection so a log reader can tell propagation from a hang. Trade-off:
a genuinely rejected helper now takes up to twenty minutes to fail instead
of five, which only delays an already-lost release; a short budget failed
good releases, each costing a full rebuild and a human retry. Both knobs
remain env-configurable (CMUX_GATEKEEPER_ASSESS_ATTEMPTS/_DELAY_SECONDS).
nightly's signing job has an 80-minute timeout with a 7-10 minute typical
duration, so the budget fits there; release.yml's timeout is raised in the
next commit.

tests/test_notarize_computer_use_helper.sh now pins the defaults (at least
1200s, polled at least every 30s, env-configurable literals) and the budget
announcement, alongside the existing override and give-up coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: raise build-sign-notarize timeout to 90 minutes

v0.64.22's build-sign-notarize took 39.8 minutes on 2026-08-03. Since then
the job gained the Cloud tunnel system extension and its Go engine build
(manaflow-ai#11789), the universal diff sidecar and cmux-tui client install (manaflow-ai#12006),
two extra smoke launches, and a Gatekeeper propagation wait that can now
run twenty minutes on its own. A 60-minute ceiling leaves no room for a
slow notarytool day, and a timeout mid-notarization wastes the whole build.

90 minutes covers the measured baseline plus the known variable waits with
headroom while still bounding a hung job on a shared self-hosted runner. To
be re-checked against the dry-run duration for this branch: the timeout must
stay at least 25 percent above it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: name the tunnel extension by its bundle identifier, not its App ID

The first release dry run that could start after the permission fix
(run 34222835589) failed 30 minutes in, at "Verify binary architectures":

  error: system extension identifier is 'com.cmuxterm.app.tunnel',
         expected '7WLXT3NR37.com.cmuxterm.app.tunnel'

manaflow-ai#11789 passed the team-prefixed App ID to
scripts/normalize-system-extension-bundle.sh and looked for the tunnel
binary under 7WLXT3NR37.com.cmuxterm.app.tunnel.systemextension. The
Release build's PRODUCT_BUNDLE_IDENTIFIER for the extension is
com.cmuxterm.app.tunnel; only NEMachServiceName
($(CMUX_TEAM_ID_PREFIX)$(PRODUCT_BUNDLE_IDENTIFIER)) and the provisioning
profile's com.apple.application-identifier carry the team prefix, and the
app activates whatever CFBundleIdentifier the bundled extension declares.
nightly.yml already does it this way and ships
com.cmuxterm.app.nightly.tunnel.systemextension with a profile for
7WLXT3NR37.com.cmuxterm.app.nightly.tunnel (run 34220568401). The mistake
was invisible until now because release.yml could not start at all.

Use the bundle identifier for the normalize call and the directory the
verify step inspects; keep the App ID for the profile check.
tests/test_ci_release_tunnel_identifiers.sh derives all three from
cmux.xcodeproj/project.pbxproj (fails on main's release.yml, passes here)
and runs in workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* Fix main's package-test compile error and Swift warning-budget violations

main is red for every branch that routes the macOS lane (manaflow-ai#12161, manaflow-ai#12165),
which keeps ci-status from ever reporting green on this release-pipeline
PR. Fix both at the root rather than refreshing the budget:

- swift-package-tests: FakeTerminalEngine.swift gained a `UUID` parameter
  in manaflow-ai#10564 but imports only GhosttyKit. Add `import Foundation`.
- tests-build-and-lag (scripts/swift_warning_budget.py, actual > budget):
  * AppDelegate+PaneMemoryGuardrail.swift: parenthesize the two
    `compactMap` closures inside the `guard` condition ("trailing closure
    in this context is confusable with the body of the statement").
  * SessionIndexTableController.swift: the bounds-change observer block is
    typed @sendable in the current SDK, so referencing `isApplyingRows` and
    `reconcilePresentation(in:)` warned. The block is delivered on
    `queue: .main`, so run it under `MainActor.assumeIsolated`, the same
    pattern SidebarWorkspaceRowCellView uses; no async hop, same timing.
  * CmuxTuiSnapshotParser.swift: `switch resourceID.kind` already covers
    every SurfaceResourceKind case (terminal, display, browser), so the
    `default: continue` could never run. Remove it; a new case now fails
    to compile here instead of being silently skipped.
  * SurfaceCatalogModel.swift: `if let rowID,` rebound a value the body
    never read; test `rowID != nil` instead.
  * TerminalController.swift: `payload` in the `.delivered` branch is never
    mutated; make it `let`.

Every change is behavior-preserving. Verified with `swiftc -parse` on each
file locally (no app build on the shared machine); the routed CI lane
proves the build and the budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* Normalize project.pbxproj (main bypassed the pre-commit hook in manaflow-ai#12145)

scripts/check-pbxproj.sh fails on main since 567ba48 (manaflow-ai#12145): the
three StackAccountAvatarViewTests.swift entries were added out of the
normalizer's sorted order, so every PR's workflow-guard-tests job goes
red at "Validate pbxproj objectVersion pin and normalization" and
linux-preflight, tests and ci-status cascade from it. This is the output
of scripts/normalize-pbxproj.py: three lines reordered, no identifier or
setting changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* tests: drive sparkle_generate_appcast.sh through the no-delta release path (red)

Release dry run 34227505375 (2026-09-08) reported "Generate Sparkle
appcast: success" and uploaded a cmux-release-dry-run artifact containing
only the DMG. The job log shows why:

  ./scripts/sparkle_generate_appcast.sh: line 93: delta_args[@]: unbound variable

A tag push would have published a GitHub Release without appcast.xml,
so no Sparkle client would ever be offered the update, and the R2 stable
appcast upload would then fail after the release already existed.

tests/test_sparkle_generate_appcast_no_deltas.sh runs the real script
with fake git/xcodebuild/generate_appcast/sign_update tools under every
bash on the machine (/bin/bash 3.2 on macOS reproduces the bug; bash 5
never did) and requires a signed appcast at the requested output path
with no delta arguments when there are no previous archives, and with
--maximum-deltas when there are. It also requires release.yml to verify
the feed after generation instead of trusting the exit status. Fails on
main's script and workflow; the next commit fixes both. Wired into
workflow-guard-tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: generate the appcast when there are no previous archives (bash 3.2)

manaflow-ai#11788 added `delta_args=()` and passed "${delta_args[@]}" to
generate_appcast. In bash 4.4+ an empty array expands to nothing; in
bash 3.2 (macOS /bin/bash, which `#!/usr/bin/env bash` resolves to on
the release runner) it is an "unbound variable" error under `set -u`.
Worse, with the script's EXIT trap bash 3.2 then exits 0, so the step
passed and no appcast was written. Nightly always has previous archives
(delta_args non-empty) and was never affected; the stable release lane
never has them and has been broken since 2026-09-03, unnoticed because
release.yml could not start at all (manaflow-ai#12149).

Expand the array as ${delta_args[@]+"${delta_args[@]}"}, which is empty
when the array is empty in every bash. In release.yml, verify after
generation that appcast.xml exists, carries sparkle:edSignature and
references cmux-macos.dmg before anything uploads it: the exit status
alone is not a reliable signal on bash 3.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* release: fail the Sparkle monotonic guard closed when the appcast is unreachable

CodeRabbit on manaflow-ai#12157: enforce mode (tag pushes, release-pretag-guard.sh)
soft-passed when the published appcast could not be fetched, so a tag
push could publish a stale CURRENT_PROJECT_VERSION on a network blip or
on a latest release that lacks appcast.xml, the exact state that leaves
Sparkle clients without updates. A missing signal must fail closed when
the run is about to publish.

enforce mode now fails with an explanation when the published build is
unknown; warn mode (non-tag dry runs) keeps the soft pass because it
publishes nothing. curl retries transient failures (3 x 2s by default,
overridable so the tests exercise the unreachable path without waiting).
tests/test_sparkle_build_monotonic_modes.sh covers enforce, default and
warn against an unreachable appcast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* tests: assert the journal-carried pane clear that manaflow-ai#11976 replaced clear_notifications with

manaflow-ai#11976 removed the v1 `clear_notifications --tab --panel` send from the
Claude prompt-submit and pre-tool-use hooks; the pane-scoped clear now
rides on the `agent.turn.started` / `agent.state.changed` journal events,
which the app reconciles into `clearNotifications(forTabId:surfaceId:)`.
It updated the Python hook tests to the new wire contract but not
ClaudeHookLifecycleCleanupTests, whose two moved-pane tests still expected
the removed command. They fail on main in the strict app-host
agent-notification step (shard 6), unnoticed because manaflow-ai#11976's PR CI never
routed the macOS lane.

Assert the new contract instead: the journal event for the hook names the
re-homed workspace and the live pane (via the existing
AgentJournalAppendCapture parser), and nothing still wipes the whole
destination workspace. SessionEnd keeps sending the v1 command, so its
tests are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: make app-host hangs fail in minutes instead of the 75-minute job timeout

Every macOS lane run since 2026-09-03 has ended with app-host shards
"cancelled" at the 75-minute job timeout. Today's logs (run 34236235360,
shards 1/2/4, both attempts) show the mechanism, and it is two plumbing
defects rather than the tests:

1. scripts/ci/xcodebuild_noninteractive.py resets its 300s idle deadline on
   every output chunk. Since manaflow-ai#11755 (merged 2026-09-03T02:09Z, after the
   last green lane at 2026-09-02T09:21Z) the app host logs every Cloud API
   poll, `[CloudVM] GET /api/vm not_signed_in`, every 45 seconds. A test
   host hung inside a WebKit page load (WebContent XPC: "Could not signal
   service ... 113") therefore never looks idle, so the wrapper's kill and
   retry path, which handled the same WebKit failure on the 09-02 green
   run, never fires.
2. The tolerant batch watchdog in ci.yml (1800s) killed only the
   console-session launcher and left the lock wrapper, xcodebuild and the
   app host alive; the app host kept the `| tee` pipe open, so the step sat
   idle from "timeout after 1800s; terminating" until the job timeout.

Fixes:
- CMUX_XCODEBUILD_NONINTERACTIVE_IDLE_IGNORE_RE: output lines matching it
  do not count as progress. run-app-host-xcodebuild.sh defaults it to the
  Cloud poll line (an empty value restores counting everything; an
  invalid regex fails closed with exit 2). Real output still resets the
  clock, so a slow but progressing batch is unaffected.
- The ci.yml batch runner writes xcodebuild output to the capture file and
  streams it with a detached tail, kills the whole process tree
  (pgrep -P recursion, TERM then KILL) when the batch budget expires, and
  reads both the streamed and per-batch captures for the SwiftPM retry
  heuristic.

Behavior tests: tests/test_ci_xcodebuild_noninteractive_helper.py drives a
child that prints only the keepalive every 50ms (finishes without the
pattern, idles out at 0.3s with it, invalid pattern exits 2);
tests/test_ci_change_areas.py runs the real step script against a runner
that hangs and leaves a grandchild holding stdout, and requires exit 124
within seconds with the grandchild dead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: keep the canonical OUTPUT capture line the SPM-retry guard pins

tests/test_ci_unit_test_spm_retry.sh requires `OUTPUT=$(cat "$TEST_OUTPUT")`
verbatim in the app-host step; the previous commit folded the per-batch
capture files into that line and turned workflow-guard-tests red. Keep the
pinned line and append the per-batch captures on the next line instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* ci: keep a watchdog-killed app-host batch terminal; drop the wall-clock assert

CodeRabbit on manaflow-ai#12157: the expected-failure normalization in
run_unit_test_batch greps the capture for the last "Executed ... failures"
summary and returns success on "(0 unexpected)". After the watchdog kills a
batch (status 124) the capture can still hold an earlier attempt's summary
(run-app-host-xcodebuild.sh retries into the same file), so a terminated
batch could be reported as passed. Treat 124 as terminal before the
normalization. The hung-runner behavior test now prints a decoy
"(0 unexpected)" summary before hanging and requires the step to stay at
124 without the "All failures ... are expected" message.

Also drop the `elapsed < 60` assertion from that test: the harness's 120s
subprocess timeout already bounds a runaway step, and a hard wall-clock
ceiling only adds scheduler-delay flakes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJWT2S5Mx2Wv3XGFih8as

* chore: normalize project file after main merge

* test: remove timing dependency from terminal lane test

* ci: accept completed app-host summaries after launcher timeout

* test: make idle watchdog coverage scheduler tolerant

* ci: require Swift Testing completion before accepting launcher timeout

* ci: fail fast on known broad app-host hangs

* test: allowlist virtual retry delay fixtures

* ci: preserve app-host lock queue headroom

* ci: restore terminal creation CLI regression coverage

* ci: retain release guard coverage after main merge

* ci: remove obsolete app-host idle override

* cmuxTests: run the Coderouter no-socket tests without an unwaited expectation

`runCoderouterCLI(waitForSocket: false)` still asked `startMockServer` for a
case-bound `expectation(description: "cli mock socket handled")` and then
never waited on it. The shared accept loop fulfills that expectation when
the listener closes at the end of the helper, so XCTest ended
`testCoderouterUnknownVerbStillPassesThroughToTheInstalledCLI` and
`testCoderouterClaudeAddOAuthTokenRejectsAPIKeyShapeBeforeTheSocket` with
"Failed due to unwaited expectation", which it counts as an *unexpected*
failure. Since manaflow-ai#12207 the app-host batch classifier fails a batch on any
unexpected failure, so this one test turned shard 5 (and the sibling test
shard 6) red on main and on every PR: run 34401456032, main run
34342638735 attempts 1 and 2.

Serve those two tests from the detached mock server instead, which owns no
expectation, and keep the waited path for every other Coderouter test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: rerun a remote tmux mirror suite once after an app-host crash

The non-tolerant "Run remote tmux mirror detach and placement regressions"
gate on shard 6 fails whenever the app host crashes mid-suite, which
manaflow-ai#9348 documents as
nondeterministic: the crash point moves between tests and the relaunched
host passes the rest (run 34401456032 crashed in
dedicatedWindowSocketDefaultsToFocusNeutral; the previous run and both
main attempts passed the same step).

Capture each suite's output and rerun the suite exactly once, only when
xcodebuild printed "Restarting after unexpected exit, crash, or test
timeout". An assertion failure never earns a rerun and a second crash
still fails the shard, so the gate keeps rejecting real regressions.

tests/test_ci_change_areas.py drives the real step script against a fake
console runner: crash-then-pass is green with three invocations, an
assertion failure exits 65 after one invocation, and two crashes exit 65
after two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(iroh): promote the replacement connection deterministically

usableConnectionRetiresOlderConnectionsFromSameEndpointIdentity keyed the
markUsable call off `recorder.recordedCount() == 2`, which both handlers
evaluate concurrently. When the first connection's handler reached that
check after the replacement had already recorded, it promoted `first`
instead, superseded the freshly admitted replacement, and the replacement's
own markUsable returned false: "Expectation failed: await
admission.markUsable()" at CmxIrohEndpointServerTests.swift:353 in CI run
34414741413 (swift-package-tests), while the previous run passed the same
code.

Admit before recording so the test's `recorder.next()` proves `first` is
active before `replacement` is enqueued, and promote only the replacement
by identity. The suite passes three consecutive local runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* cmuxTests: serialize the stdin pump suite and bound its blocking waits

Shard 3 of CI run 34414741413 hung three times at the app-host wrapper's
300s idle timeout in the same batch. The hang sample shows
SSHPTYAttachReconnectInputFilterTests.stdinPumpFiltersReadyInputBeforeStopSignal
parked in stopFiltering() -> read() on the stop-acknowledgement pipe with
every other visible cooperative-pool thread also inside a test body's
synchronous wait. The pump under test is a detached task that needs one of
those same threads, and the five pump tests each hold a thread for the
~13s reconnect probe deadline while running concurrently, so the suite can
leave no thread for any pump (the family issue manaflow-ai#12180 tracks).

Run the suite serialized so at most one test parks a thread at a time, and
bound every wait: stopFiltering now takes a 30s acknowledgement timeout and
must succeed, and the EOF/exact reads poll with the same deadline. A pump
that never gets scheduled now fails its test inside the batch instead of
parking the shard until the idle timeout retries are exhausted.

The two assertions in this suite that already fail on main
(readUntilEOF == forwardedInput in stdinPumpFiltersReadyInputBeforeStopSignal
and stdinPumpKeepsFilteringLateProbeRepliesAfterInitialDrain) are
unchanged; the batch classifier tolerates them today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 8e690b31 Deployed Jul 14, 2026 by vercel[bot]
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