diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 79185e4cb7da..cabeeebb2a2f 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -1,50 +1,34 @@ name: iOS E2E -# End-to-end Mac<->iPhone gate: a real Mac app on one runner, a real iOS -# simulator app on another, and the dev web backend on the durable tailnet VM. -# The iOS side signs in, pairs to the remote Mac through the backend, connects -# over Iroh, and drives a 6-step streamed-terminal script whose steps each -# cover a shipped regression (scripts/e2e/README.md). Topology, ACLs, secrets -# and the promotion plan live in docs/ci/ios-e2e.md. +# End-to-end Mac-to-iPhone gate: one macOS runner builds and runs the tagged +# Mac app and iOS simulator app, while the dev web backend runs on the durable +# GCP VM. The apps use the backend for sign-in and pairing, then the terminal +# data path is forced through Iroh relays. Topology, ACLs, secrets, and the +# promotion plan live in docs/ci/ios-e2e.md. # -# No workflow-level path filter on purpose: the `route` job decides skips so -# the `ios-e2e-status` aggregate always reports a deterministic conclusion. -# ci.yml takes the same approach — a paths: filter leaves the check MISSING -# on unrelated PRs, which branch protection cannot require; a routed skip -# still reports green. -on: - workflow_dispatch: - pull_request: +# No workflow-level path filter on purpose: the route job decides skips so the +# ios-e2e-status aggregate always reports a deterministic conclusion. +# The pull_request trigger stays off until the owner approves promotion (see +# docs/ci/ios-e2e.md). The workflow is dispatchable while infrastructure and +# the product path are validated. +# pull_request: +on: [workflow_dispatch] permissions: contents: read -# A newer push to the same pull request replaces its run; dispatches never -# cancel each other (same shape as test-ios.yml). concurrency: group: ${{ github.event_name == 'pull_request' && format('ios-e2e-pr-{0}', github.event.pull_request.number) || format('ios-e2e-{0}', github.run_id) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - # The whole teardown handshake is one file touched over Tailscale SSH: the - # Mac host's wait loop watches this local path instead of polling the - # GitHub API for the iOS job's status. A ~25-minute per-PR status-poll loop - # would draw down the repo-wide API rate limit that every other workflow - # shares (secondary-rate-limit stalls have hit this repo's CI before), and - # a local file needs no token on the Mac at all. - CMUX_E2E_DONE_FILE: /tmp/e2e-done-${{ github.run_id }} - # Deterministic tailnet hostname for the Mac host. The iOS job must address - # the Mac while BOTH jobs are still running, and GitHub job outputs only - # publish when the producing job completes — so the name is derived from - # the run id up front rather than exchanged at runtime. - CMUX_E2E_MAC_TAILNET_HOSTNAME: cmux-e2e-mac-${{ github.run_id }} CMUX_E2E_BACKEND_HOST: cmux-dev-backend-1.tail137216.ts.net jobs: route: - # Decides whether the lane runs and which backend stack tag it uses. - # Cheap Linux layer so every PR gets a routed conclusion (see header). - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-4vcpu-ubuntu-2404' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + # Cheap Linux layer so every PR gets a routed conclusion once the trigger + # is promoted. The classifier integration remains intentionally explicit. + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 outputs: run_e2e: ${{ steps.decide.outputs.run_e2e }} @@ -54,8 +38,8 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - # Depth 2 reaches both parents of the PR merge commit, which is all - # the backend-tag path check below needs to diff. + # Depth 2 reaches both parents of a PR merge commit, which is all + # the backend-tag path check needs to diff. fetch-depth: 2 persist-credentials: false @@ -66,35 +50,19 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail - # TODO(ios-e2e): stub router. Integrate - # scripts/ci/detect_ci_change_areas.py (the classifier ci.yml's - # `changes` job uses) so docs-only and unrelated diffs skip both - # macOS runners; until then every routed run says "run" and the - # lane's cost is bounded by it not being required (shadow mode, - # docs/ci/ios-e2e.md#promotion-plan). - run_e2e=false - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - run_e2e=true - fi - # Backend stack selection: a PR that changes web/ (API routes, - # services, drizzle schema — the code the stack actually runs) must - # not land its schema or API changes on the shared long-lived - # ci-main stack. It gets an isolated per-PR stack, tag ci, - # reused across pushes so the stack keeps its database (dev-backend - # semantics: re-ensuring a tag updates its source in place). - # TODO(ios-e2e): align this path set with the `web` area in - # scripts/ci/detect_ci_change_areas.py instead of one regex. + # TODO(ios-e2e): integrate scripts/ci/detect_ci_change_areas.py so + # docs-only and unrelated diffs skip the expensive Mac job. Until + # then the lane runs in shadow mode when dispatched. + run_e2e=true web_changed=false if [ "$EVENT_NAME" = "pull_request" ]; then if git rev-parse -q --verify HEAD^2 >/dev/null; then - # HEAD is the PR merge commit; parent 1 is the base branch, so - # this diff is exactly the PR's changed files, no API call. if git diff --name-only HEAD^1 HEAD | grep -Eq '^web/'; then web_changed=true fi else - # No merge commit to diff (detached/rebase edge): isolate - # rather than risk mutating the shared stack. + # Isolate a rebase or detached edge rather than mutating the + # shared stack when the changed-file diff is unavailable. web_changed=true fi fi @@ -110,267 +78,205 @@ jobs: } >> "$GITHUB_OUTPUT" echo "route: run_e2e=$run_e2e web_changed=$web_changed backend_tag=$backend_tag" - backend: - # Ensures a dev backend stack (web + Postgres) for this run's tag on the - # durable VM and proves the runner-side tailnet path to it before either - # macOS job spends its queue slot. + mac-ios-e2e: + # The Mac and simulator share one runner. That removes the runner-to-runner + # network shortcut while relay-only policy keeps terminal traffic on the + # same Iroh path the gate is meant to exercise. needs: route - # Every job that mounts secrets is fenced off from fork heads: a fork PR - # controls this workflow file's content, so it must never reach the - # tailnet OAuth client or the CI Stack account. success() must be spelled - # out — a custom `if:` replaces the implicit needs-succeeded condition. - # The aggregate reports forks as a neutral skip. if: >- success() && needs.route.outputs.run_e2e == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-4vcpu-ubuntu-2404' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} - timeout-minutes: 10 - outputs: - backend_url: ${{ steps.ensure.outputs.backend_url }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-14' || vars.MACOS_RUNNER_IOS || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-26' }} + timeout-minutes: 120 + env: + CMUX_E2E_TAG: ${{ needs.route.outputs.backend_tag }} + CMUX_IROH_V2_FORCE_RELAY: "1" + CMUX_SKIP_ZIG_BUILD: "1" + SWIFT_BACKTRACE: interactive=no,timeout=0s,symbolicate=off,color=no steps: - - name: Join tailnet - # TODO(ios-e2e): pin to a commit SHA like the other third-party - # actions in this repo once the action version settles. - uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd + - name: Checkout cmux + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} - oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} - tags: tag:ci + persist-credentials: false + submodules: recursive + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh - - name: Ping backend host + - name: Install build prerequisites run: | set -euo pipefail - # [infra-preflight] reds here are tailnet/ACL problems, never a - # product regression; the label keeps them out of flake triage - # (docs/ci/ios-e2e.md#infra-preflight-failure-labeling). - tailscale ping --timeout 5s -c 5 "$CMUX_E2E_BACKEND_HOST" || { - echo "::error::[infra-preflight] cannot reach $CMUX_E2E_BACKEND_HOST over the tailnet (ACL: tag:ci -> backend host)" + ./scripts/install-zig-ci.sh + ./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh + brew_bin="$(brew --prefix)/bin" + export PATH="$brew_bin:$PATH" + echo "$brew_bin" >> "$GITHUB_PATH" + if ! command -v axe >/dev/null 2>&1; then + brew install cameroncooke/axe/axe + fi + command -v axe >/dev/null 2>&1 || { + echo "::error::[infra-preflight] axe is missing from the simulator runner" exit 1 } - - name: Ensure backend stack - id: ensure - env: - BACKEND_TAG: ${{ needs.route.outputs.backend_tag }} - run: | - set -euo pipefail - # TODO(ios-e2e): real ensure call. cmuxterm-hq's - # scripts/dev-backend.sh (shim for skills/infra/dev-backend/ - # dev-backend.sh) owns these stacks: `dev-backend.sh url --tag - # ` ensures the tagged web+Postgres Docker stack on the VM and - # prints its private Tailscale Serve URL; `status` shows the - # 12-running / 64-registered stack budget this job must respect - # before ensuring. It drives the VM's control API over SSH, so CI - # needs a dedicated deploy key — secret CMUX_DEV_BACKEND_SSH_KEY, - # NOT yet provisioned — loaded into an ssh-agent here and never - # written to disk or echoed. Until that lands, emit the serve - # origin shape so the drivers fail loudly against a missing stack - # instead of a missing variable. - backend_url="https://${CMUX_E2E_BACKEND_HOST}" - echo "backend_url=$backend_url" >> "$GITHUB_OUTPUT" - echo "ensure (stub): tag=$BACKEND_TAG url=$backend_url" >> "$GITHUB_STEP_SUMMARY" - - mac-host: - # Compiles nothing: reuses the prebuilt tagged Mac app, signs it into the - # CI Stack account, advertises it through the backend so the iOS client - # can discover it, then blocks on the done-file until the iOS job signals - # (or the bounded wait expires). Starts in PARALLEL with ios-e2e — the - # sim's sign-in/pair sequence retries until the Mac is advertised, so - # serializing the jobs would only add queue time. - needs: [route, backend] - if: >- - success() - && needs.route.outputs.run_e2e == 'true' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }} - timeout-minutes: 45 - outputs: - # Debug/audit only. A job output is readable by dependents only after - # this job completes, and this job completes only after the signal — - # the iOS job addresses the Mac by CMUX_E2E_MAC_TAILNET_HOSTNAME (the - # deterministic name it joined with) instead. - tailnet_hostname: ${{ steps.tailnet-name.outputs.hostname }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - # The app arrives prebuilt; the checkout is for the driver scripts. - submodules: false - - - name: Select Xcode - run: ./scripts/select-ci-xcode.sh + - name: Ensure iOS simulator runtime + run: xcrun simctl list runtimes available | grep -Eq '\biOS\b' || xcodebuild -downloadPlatform iOS - - name: Download tagged Mac app product + - name: Materialize CI auth profile + env: + CMUX_DOGFOOD_STACK_EMAIL: ${{ secrets.CMUX_DOGFOOD_STACK_EMAIL }} + CMUX_DOGFOOD_STACK_PASSWORD: ${{ secrets.CMUX_DOGFOOD_STACK_PASSWORD }} run: | set -euo pipefail - # TODO(ios-e2e): reuse the compiled Mac app instead of building. - # ci-macos.yml's admission job is the pattern: `python3 - # scripts/ci/reuse_app_host_products.py key ` derives - # the reuse key from the source/Xcode fingerprint, then `... restore - # ` (GH_TOKEN in env) pulls a producer-`seal`ed - # Build/Products tree from a prior run's artifact or its R2 copy - # into an isolated DerivedData root, with hit/miss reasons on the - # step outputs. This job wants exactly that consumer path plus the - # tag stamping scripts/reload.sh applies (bundle - # com.cmuxterm.app.debug., socket /tmp/cmux-debug-.sock), - # and needs `permissions: actions: read` once wired. Until then, - # fail fast with the infra label rather than idle for 45 minutes. - echo "::error::[infra-preflight] Mac app product download not implemented (see reuse_app_host_products.py TODO)" - exit 1 + if [ -z "${CMUX_DOGFOOD_STACK_EMAIL:-}" ] || [ -z "${CMUX_DOGFOOD_STACK_PASSWORD:-}" ]; then + echo "::error::[infra-preflight] CI Stack credentials are missing" + exit 1 + fi + mkdir -p "$HOME/.secrets" + ( + umask 077 + # The agent profile reads CMUX_UITEST_*; the secret names retain + # their existing CI account naming for compatibility. + printf 'CMUX_UITEST_STACK_EMAIL=%s\n' "$CMUX_DOGFOOD_STACK_EMAIL" + printf 'CMUX_UITEST_STACK_PASSWORD=%s\n' "$CMUX_DOGFOOD_STACK_PASSWORD" + ) > "$HOME/.secrets/cmuxterm-dev.env" + chmod 600 "$HOME/.secrets/cmuxterm-dev.env" - name: Join tailnet - # TODO(ios-e2e): pin to a commit SHA (see backend job); also confirm - # the action's macOS-runner support on the Blacksmith image. - uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd + uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4 with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} tags: tag:ci - # Deterministic name (see workflow env): the parallel iOS job must - # be able to SSH here without a runtime hostname exchange. - hostname: ${{ env.CMUX_E2E_MAC_TAILNET_HOSTNAME }} - - name: Publish tailnet hostname - id: tailnet-name + - name: Verify backend host reachability run: | set -euo pipefail - # TODO(ios-e2e): the original design exchanged the hostname from - # `tailscale status --self --json` via a job output written before - # the wait. That cannot work for a PARALLEL consumer — job outputs - # publish only when the job completes, which here is after the - # signal — so the deterministic join hostname above is the real - # addressing mechanism. This output stays for logs, the aggregate, - # and any future sequential consumer. - hostname="$(tailscale status --self --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')" - echo "hostname=$hostname" >> "$GITHUB_OUTPUT" - echo "tailnet hostname: $hostname (expected prefix: $CMUX_E2E_MAC_TAILNET_HOSTNAME)" + tailscale ping --timeout 5s -c 5 "$CMUX_E2E_BACKEND_HOST" || { + echo "::error::[infra-preflight] cannot reach $CMUX_E2E_BACKEND_HOST over the tailnet" + exit 1 + } - - name: Launch Mac host and wait for the iOS job - # Step ceiling above the driver's own ~25m bounded wait so a wedged - # driver can never consume the whole job timeout doing nothing. - timeout-minutes: 30 - env: - CMUX_E2E_TAG: ${{ needs.route.outputs.backend_tag }} - CMUX_DEV_BACKEND_URL: ${{ needs.backend.outputs.backend_url }} - # ~25m bounded done-file loop inside the driver; the done-file path - # itself comes from the workflow env (no GitHub API polling — see - # the CMUX_E2E_DONE_FILE comment at the top). - CMUX_E2E_WAIT_TIMEOUT_SECONDS: "1500" - # Dedicated CI Stack account, the same secret pair - # ios-streamed-validate.yml uses; the app's dev-secrets resolution - # reads CMUX_DOGFOOD_STACK_* from the environment first. Values - # travel only through the environment — never echoed, never argv, - # never written to disk. - CMUX_DOGFOOD_STACK_EMAIL: ${{ secrets.CMUX_DOGFOOD_STACK_EMAIL }} - CMUX_DOGFOOD_STACK_PASSWORD: ${{ secrets.CMUX_DOGFOOD_STACK_PASSWORD }} - run: ./scripts/e2e/mac-host.sh + - name: Ensure GCP backend stack + id: backend + run: | + set -euo pipefail + helper="$GITHUB_WORKSPACE/scripts/e2e/gcp-backend.sh" + "$helper" start \ + --tag "$CMUX_E2E_TAG" \ + --checkout "$GITHUB_WORKSPACE" + backend_url="$("$helper" url --tag "$CMUX_E2E_TAG")" + case "$backend_url" in + https://cmux-dev-backend-1.tail137216.ts.net:*) ;; + *) + echo "::error::[infra-preflight] backend helper returned an unexpected URL shape" + exit 1 + ;; + esac + backend_ready=false + for _ in $(seq 1 30); do + if curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \ + "${backend_url%/}/handler/sign-in" >/dev/null; then + backend_ready=true + break + fi + sleep 2 + done + if [ "$backend_ready" != "true" ]; then + echo "::error::[infra-preflight] ensured backend did not serve /handler/sign-in" + exit 1 + fi + { + echo "CMUX_DEV_BACKEND_URL=$backend_url" + echo "CMUX_DEV_API_BASE_URL=$backend_url" + echo "CMUX_IROH_BROKER_BASE_URL=$backend_url" + } >> "$GITHUB_ENV" + echo "backend: ensured tag=$CMUX_E2E_TAG on $CMUX_E2E_BACKEND_HOST" >> "$GITHUB_STEP_SUMMARY" - ios-e2e: - # The client half: fresh named simulator, prebuilt sim app, sign-in -> - # pair -> Iroh connect -> 6-step terminal script (scripts/e2e/README.md). - # Runs in parallel with mac-host (see that job's comment) and ALWAYS - # signals the Mac's done-file at the end, pass or fail, so the Mac never - # waits out its full timeout on a failed client. - needs: [route, backend] - if: >- - success() - && needs.route.outputs.run_e2e == 'true' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - # Simulator-capable pool: MACOS_RUNNER_IOS is the variable the iOS - # simulator lanes read (test-ios.yml, ios-streamed-validate.yml). - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - submodules: false + - name: Configure Mac relay-only policy + run: | + set -euo pipefail + source scripts/lib/mobile-attach.sh + mac_bundle_id="$(cmux_attach_mac_bundle_id "$CMUX_E2E_TAG")" + defaults write "$mac_bundle_id" cmux.iroh.debug.transport-mode -string relayOnly + defaults write "$mac_bundle_id" cmux.iroh.v2.force-relay -bool true - - name: Select Xcode - # Canonical fleet selector (ranks by macOS SDK, aligns xcode-select); - # simctl needs a toolchain with an iOS 26 simulator runtime. - run: ./scripts/select-ci-xcode.sh + - name: Boot fresh named simulator + run: | + set -euo pipefail + devtype="$(xcrun simctl list devicetypes -j | python3 -c 'import json,sys; ts=[t["identifier"] for t in json.load(sys.stdin)["devicetypes"] if t.get("productFamily")=="iPhone"]; print(ts[-1])')" + udid="$(xcrun simctl create "cmux-e2e-${GITHUB_RUN_ID}" "$devtype")" + xcrun simctl boot "$udid" + xcrun simctl bootstatus "$udid" -b + echo "CMUX_E2E_SIM_UDID=$udid" >> "$GITHUB_ENV" + echo "simulator: $udid ($devtype)" - - name: Ensure iOS simulator runtime - run: xcrun simctl list runtimes available | grep -Eq '\biOS\b' || xcodebuild -downloadPlatform iOS + - name: Build and launch Mac app + run: | + set -euo pipefail + ./scripts/reload.sh \ + --tag "$CMUX_E2E_TAG" \ + --launch \ + --auth-profile agent \ + --credentials-file "$HOME/.secrets/cmuxterm-dev.env" \ + --swift-frontend-workaround - - name: Download iOS simulator app product + - name: Build iOS simulator app run: | set -euo pipefail - # TODO(ios-e2e): reuse the iOS lane's compiled product instead of - # building. test-ios.yml's ios-simulator-build job stages and stamps - # the simulator Build/Products tree - # (scripts/ci/ios_simulator_test_product.py stamp) and uploads it - # as artifact `ios-test-product--` (tar.gz); - # its consumers download by artifact id, trying - # scripts/ci/parallel_artifact_download.py first. This lane needs a - # cross-workflow lookup of the newest compatible product for this - # head SHA (the way reuse_app_host_products.py locates Mac - # products), or a tagged ios/scripts/reload.sh-style build as the - # cold fallback. Caveat to resolve while wiring: that artifact is - # the TEST-host product — confirm its bundle id and entitlements - # suit the dogfood sign-in path (dev.cmux.ios.) or re-stamp. - # Needs `permissions: actions: read` once wired. Until then, fail - # fast with the infra label. - echo "::error::[infra-preflight] iOS sim app product download not implemented (see test-ios.yml product-upload TODO)" - exit 1 + ./ios/scripts/reload.sh \ + --tag "$CMUX_E2E_TAG" \ + --simulator-id "$CMUX_E2E_SIM_UDID" \ + --simulator-only \ + --no-launch \ + --swift-frontend-workaround - - name: Join tailnet - # TODO(ios-e2e): pin to a commit SHA (see backend job). - uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd - with: - oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} - oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} - tags: tag:ci - hostname: cmux-e2e-ios-${{ github.run_id }} + - name: Configure and verify simulator relay-only policy + run: | + set -euo pipefail + source scripts/lib/mobile-attach.sh + mac_bundle_id="$(cmux_attach_mac_bundle_id "$CMUX_E2E_TAG")" + ios_bundle_id="dev.cmux.ios.$(cmux_attach__slug "$CMUX_E2E_TAG")" + xcrun simctl spawn "$CMUX_E2E_SIM_UDID" defaults write \ + "$ios_bundle_id" cmux.iroh.debug.transport-mode -string relayOnly + xcrun simctl spawn "$CMUX_E2E_SIM_UDID" defaults write \ + "$ios_bundle_id" cmux.iroh.v2.config.CMUX_IROH_V2_FORCE_RELAY -string 1 + [ "$(defaults read "$mac_bundle_id" cmux.iroh.debug.transport-mode)" = "relayOnly" ] + [ "$(defaults read "$mac_bundle_id" cmux.iroh.v2.force-relay)" = "1" ] + [ "$(xcrun simctl spawn "$CMUX_E2E_SIM_UDID" defaults read "$ios_bundle_id" cmux.iroh.debug.transport-mode)" = "relayOnly" ] + [ "$(xcrun simctl spawn "$CMUX_E2E_SIM_UDID" defaults read "$ios_bundle_id" cmux.iroh.v2.config.CMUX_IROH_V2_FORCE_RELAY)" = "1" ] + echo "relay policy: Mac and iOS simulator forced to relayOnly" >> "$GITHUB_STEP_SUMMARY" - - name: Boot fresh named simulator + - name: Sign in and pair simulator run: | set -euo pipefail - # Per-run named sim so parallel runs on a shared mini never boot, - # install onto, or reset each other's device; the newest iPhone - # device type on the image keeps this from pinning a model name - # that ages out of the runner image. - DEVTYPE="$(xcrun simctl list devicetypes -j | python3 -c 'import json,sys; ts=[t["identifier"] for t in json.load(sys.stdin)["devicetypes"] if t.get("productFamily")=="iPhone"]; print(ts[-1])')" - UDID="$(xcrun simctl create "cmux-e2e-${GITHUB_RUN_ID}" "$DEVTYPE")" - xcrun simctl boot "$UDID" - xcrun simctl bootstatus "$UDID" -b - echo "CMUX_E2E_SIM_UDID=$UDID" >> "$GITHUB_ENV" - echo "sim: cmux-e2e-${GITHUB_RUN_ID} ($DEVTYPE) $UDID" + ./scripts/mobile-dev-launch.sh \ + --tag "$CMUX_E2E_TAG" \ + --simulator-id "$CMUX_E2E_SIM_UDID" \ + --ensure-mac \ + --detach \ + --auth-profile agent \ + --credentials-file "$HOME/.secrets/cmuxterm-dev.env" + + - name: Verify relay policy after launch + run: | + set -euo pipefail + source scripts/lib/mobile-attach.sh + mac_bundle_id="$(cmux_attach_mac_bundle_id "$CMUX_E2E_TAG")" + ios_bundle_id="dev.cmux.ios.$(cmux_attach__slug "$CMUX_E2E_TAG")" + [ "$(defaults read "$mac_bundle_id" cmux.iroh.v2.force-relay)" = "1" ] + [ "$(xcrun simctl spawn "$CMUX_E2E_SIM_UDID" defaults read "$ios_bundle_id" cmux.iroh.v2.config.CMUX_IROH_V2_FORCE_RELAY)" = "1" ] - name: Run iOS E2E env: - CMUX_E2E_TAG: ${{ needs.route.outputs.backend_tag }} - CMUX_DEV_BACKEND_URL: ${{ needs.backend.outputs.backend_url }} CMUX_E2E_EVIDENCE_DIR: ${{ runner.temp }}/e2e-evidence - # Same CI Stack account as mac-host: pairing's same-account RPC - # gate requires both ends to resolve one account. Environment-only, - # never echoed. - CMUX_DOGFOOD_STACK_EMAIL: ${{ secrets.CMUX_DOGFOOD_STACK_EMAIL }} - CMUX_DOGFOOD_STACK_PASSWORD: ${{ secrets.CMUX_DOGFOOD_STACK_PASSWORD }} - run: ./scripts/e2e/ios-e2e-run.sh - - - name: Signal Mac host teardown - # Always, pass or fail: this touch is what releases the Mac host's - # done-file wait. It rides Tailscale SSH because the ACL grants - # tag:ci -> tag:ci on port 22 ONLY — deliberately nothing wider, so - # Iroh's path probing can never carry the terminal stream between the - # runners over the tailnet and silently bypass the transport this - # lane exists to gate (docs/ci/ios-e2e.md#tailscale-acl-requirements). - if: always() run: | set -euo pipefail - # TODO(ios-e2e): confirm the login user Tailscale SSH maps for the - # Blacksmith macOS runner account in the tailnet ACL ("runner" - # assumed here). Best-effort: a missed signal only costs the Mac - # its own bounded wait, so it must not repaint a green E2E red. - ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \ - "runner@${CMUX_E2E_MAC_TAILNET_HOSTNAME}" \ - "touch '${CMUX_E2E_DONE_FILE}'" \ - || echo "::warning::[infra-preflight] could not signal ${CMUX_E2E_MAC_TAILNET_HOSTNAME}; its wait expires at its own ~25m bound" + ./scripts/e2e/ios-e2e-run.sh \ + --tag "$CMUX_E2E_TAG" \ + --sim-udid "$CMUX_E2E_SIM_UDID" \ + --evidence-dir "$CMUX_E2E_EVIDENCE_DIR" - name: Upload E2E evidence if: always() @@ -381,23 +287,45 @@ jobs: if-no-files-found: warn retention-days: 7 + - name: Stop tagged Mac app + if: always() + run: | + set -euo pipefail + pkill -f "DerivedData/cmux-${CMUX_E2E_TAG}/.*/cmux DEV" 2>/dev/null || true + - name: Delete simulator if: always() run: | + set -euo pipefail [ -n "${CMUX_E2E_SIM_UDID:-}" ] || exit 0 xcrun simctl shutdown "$CMUX_E2E_SIM_UDID" 2>/dev/null || true xcrun simctl delete "$CMUX_E2E_SIM_UDID" || true + - name: Remove backend stack + if: always() + run: | + set -euo pipefail + helper="$GITHUB_WORKSPACE/scripts/e2e/gcp-backend.sh" + if [ -f "$HOME/.local/state/cmux/dev-backends/${CMUX_E2E_TAG}.json" ]; then + "$helper" remove --tag "$CMUX_E2E_TAG" || echo "::warning::[infra-preflight] backend cleanup failed" + elif [ -f "${CMUX_E2E_BACKEND_STATE_DIR:-${RUNNER_TEMP:-/tmp}/cmux-gcp-backend}/${CMUX_E2E_TAG}.json" ]; then + "$helper" remove --tag "$CMUX_E2E_TAG" || echo "::warning::[infra-preflight] backend cleanup failed" + fi + + - name: Remove CI auth profile + if: always() + run: | + rm -f "$HOME/.secrets/cmuxterm-dev.env" + rmdir "$HOME/.secrets" 2>/dev/null || true + ios-e2e-status: - # The one conclusion branch protection will eventually require - # (docs/ci/ios-e2e.md#promotion-plan): green when the route skipped the - # lane or every needed job passed; red when the route said run and any - # needed job failed, was cancelled, or was skipped unexpectedly. Fork PRs - # are a neutral skip — the secret-fenced jobs cannot run there, and a red - # would block every outside contribution. - needs: [route, backend, mac-host, ios-e2e] + # The one conclusion branch protection will eventually require: green when + # the route skipped the lane or the Mac job passed, red when the route ran + # and the Mac job failed. Fork PRs are a neutral skip because secret-fenced + # jobs cannot run there. + needs: [route, mac-ios-e2e] if: always() - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-4vcpu-ubuntu-2404' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Aggregate @@ -405,21 +333,17 @@ jobs: ROUTE_RESULT: ${{ needs.route.result }} RUN_E2E: ${{ needs.route.outputs.run_e2e }} BACKEND_TAG: ${{ needs.route.outputs.backend_tag }} - BACKEND_RESULT: ${{ needs.backend.result }} - MAC_RESULT: ${{ needs.mac-host.result }} - IOS_RESULT: ${{ needs.ios-e2e.result }} + MAC_IOS_RESULT: ${{ needs.mac-ios-e2e.result }} IS_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} run: | set -euo pipefail { echo "### iOS E2E" echo "- route: $ROUTE_RESULT (run_e2e=${RUN_E2E:-}, backend tag=${BACKEND_TAG:-})" - echo "- backend: $BACKEND_RESULT · mac-host: $MAC_RESULT · ios-e2e: $IOS_RESULT" + echo "- mac-ios-e2e: $MAC_IOS_RESULT" } >> "$GITHUB_STEP_SUMMARY" if [ "$IS_FORK" = "true" ]; then - # Neutral skip: report it and pass without judging jobs the fork - # fence intentionally skipped. - echo "- conclusion: neutral skip (fork PR; secret-fenced jobs cannot run)" >> "$GITHUB_STEP_SUMMARY" + echo "- conclusion: neutral skip (fork PR; secret-fenced job cannot run)" >> "$GITHUB_STEP_SUMMARY" exit 0 fi if [ "$ROUTE_RESULT" != "success" ]; then @@ -430,15 +354,8 @@ jobs: echo "- conclusion: pass (route skipped the lane)" >> "$GITHUB_STEP_SUMMARY" exit 0 fi - fail=0 - for pair in "backend:$BACKEND_RESULT" "mac-host:$MAC_RESULT" "ios-e2e:$IOS_RESULT"; do - job="${pair%%:*}"; result="${pair#*:}" - if [ "$result" != "success" ]; then - echo "::error::$job: $result (needed because route said run)" - fail=1 - fi - done - if [ "$fail" -ne 0 ]; then + if [ "$MAC_IOS_RESULT" != "success" ]; then + echo "::error::mac-ios-e2e: $MAC_IOS_RESULT (needed because route said run)" echo "- conclusion: FAIL" >> "$GITHUB_STEP_SUMMARY" exit 1 fi diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift index c343ab4bc815..b2404e645a59 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift @@ -391,7 +391,7 @@ public actor IrxConnection { /// is the relay URL for relayed paths and the socket address otherwise. public nonisolated func selectedPath() -> (isRelay: Bool, remoteAddress: String)? { let paths = connection.paths() - guard let selected = paths.first(where: { $0.isSelected }) else { + guard let selected = paths.first(where: { $0.isSelected }) ?? paths.first else { return nil } return (selected.isRelay, "\(selected.remoteAddr)") diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index a060b4f8a728..cfefc4b7ccb1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -28,15 +28,6 @@ public final class MobileWhatsNewCenter { static let markerKey = "dev.cmux.mobile.whatsNew.newestAcknowledgedEntryId" - #if DEBUG - /// `CMUX_UITEST_SUPPRESS_WHATS_NEW=1` (environment or launch argument) - /// keeps the launch sheet away during automated Debug runs. Debug-only, - /// mirroring the other `CMUX_UITEST_*` hooks in `UITestConfig`. - static var suppressedForAutomation: Bool { - ProcessInfo.processInfo.environment["CMUX_UITEST_SUPPRESS_WHATS_NEW"] == "1" - || ProcessInfo.processInfo.arguments.contains("CMUX_UITEST_SUPPRESS_WHATS_NEW=1") - } - #endif static let acknowledgedAnnouncementsKey = "dev.cmux.mobile.whatsNew.acknowledgedAnnouncementIds" static let cacheKey = "dev.cmux.mobile.whatsNew.remoteList.v1" static let requestPath = "/api/whats-new" @@ -230,14 +221,6 @@ public final class MobileWhatsNewCenter { /// advances past a page that was skipped this way unless a newer binary /// page was acknowledged above it). var unseenPages: [MobileWhatsNewPage] { - #if DEBUG - // Automated drivers (the Iroh release gate, the iOS e2e gate) run a - // fresh install every time, so the launch sheet would cover the - // workspace UI and block their readiness probes. The knob suppresses - // presentation only; markers are untouched, so a normal launch of the - // same container still shows the pages. - if Self.suppressedForAutomation { return [] } - #endif let acknowledged = acknowledgedAnnouncementIDs let unseenAnnouncements = announcementPages.filter { !acknowledged.contains($0.id) } let visible = visibleBinaryEntries @@ -337,4 +320,3 @@ public final class MobileWhatsNewCenter { } #endif - diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewPresentationPolicy.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewPresentationPolicy.swift new file mode 100644 index 000000000000..84b5b83abdf2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewPresentationPolicy.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// Composition-level policy for the one-time What's New launch sheet. +/// +/// Debug automation can suppress the launch presentation while keeping the +/// catalog and acknowledgement state intact. The policy is supplied by the +/// app composition root so the catalog model remains independent of process +/// environment variables. +public struct MobileWhatsNewPresentationPolicy: Sendable { + public var suppressLaunchPresentation: Bool + + public init(suppressLaunchPresentation: Bool = false) { + self.suppressLaunchPresentation = suppressLaunchPresentation + } +} + +private struct MobileWhatsNewPresentationPolicyKey: EnvironmentKey { + static let defaultValue = MobileWhatsNewPresentationPolicy() +} + +public extension EnvironmentValues { + var mobileWhatsNewPresentationPolicy: MobileWhatsNewPresentationPolicy { + get { self[MobileWhatsNewPresentationPolicyKey.self] } + set { self[MobileWhatsNewPresentationPolicyKey.self] = newValue } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 70f1585e295f..aaec5d679b65 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -229,6 +229,7 @@ struct WorkspaceShellView: View { /// sheet presents, so remote list changes mid-presentation cannot mutate /// an open sheet. @Environment(MobileWhatsNewCenter.self) private var whatsNewCenter: MobileWhatsNewCenter? + @Environment(\.mobileWhatsNewPresentationPolicy) private var whatsNewPresentationPolicy @Environment(\.mobileWebAppSession) private var whatsNewWebAppSession @Environment(\.colorScheme) private var whatsNewColorScheme @State private var whatsNewSheetPages: [MobileWhatsNewPage] = [] @@ -699,7 +700,8 @@ struct WorkspaceShellView: View { /// sheet already occupying the presenter) never marks pages as seen. private func presentWhatsNewIfNeeded() { guard let whatsNewCenter, whatsNewCenter.hasCompletedInitialRefresh, - !showsWhatsNewSheet else { return } + !showsWhatsNewSheet, + !whatsNewPresentationPolicy.suppressLaunchPresentation else { return } let pages = whatsNewCenter.unseenPages guard !pages.isEmpty else { return } whatsNewCandidatePages = pages diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift index 07f3a93cb16f..a2599c46ee03 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -157,6 +157,30 @@ public struct UITestConfig { #endif } + /// Suppresses the one-time What's New launch sheet for automated Debug + /// launches. The app composition root converts this input into an explicit + /// presentation policy; the catalog model does not read process state. + public static var suppressWhatsNewLaunch: Bool { + suppressWhatsNewLaunch( + from: ProcessInfo.processInfo.environment, + arguments: ProcessInfo.processInfo.arguments + ) + } + + /// Resolves the What's New launch suppression flag from explicit process + /// inputs so the Debug composition policy remains testable. + public static func suppressWhatsNewLaunch( + from env: [String: String], + arguments: [String] = [] + ) -> Bool { + #if DEBUG + return env["CMUX_UITEST_SUPPRESS_WHATS_NEW"] == "1" + || arguments.contains("CMUX_UITEST_SUPPRESS_WHATS_NEW=1") + #else + return false + #endif + } + /// Whether the full-app UI-test harness should treat the account-owned /// revoke step of Forget Computer as successful. The remaining operation, /// including durable paired-Mac deletion, store refresh, shell routing, and diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift index eabda1026fc2..cb775d91b6c1 100644 --- a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift @@ -61,6 +61,47 @@ import Testing #expect(UITestConfig.value(for: "CMUX_UITEST_ADD_DEVICE_HOST", env: env) == nil) } + @Test func whatsNewLaunchSuppressionUsesEnvironmentOrArgument() { + #if DEBUG + #expect( + UITestConfig.suppressWhatsNewLaunch( + from: ["CMUX_UITEST_SUPPRESS_WHATS_NEW": "1"] + ) + ) + #expect( + UITestConfig.suppressWhatsNewLaunch( + from: [:], + arguments: ["CMUX_UITEST_SUPPRESS_WHATS_NEW=1"] + ) + ) + #expect( + UITestConfig.suppressWhatsNewLaunch( + from: ["CMUX_UITEST_SUPPRESS_WHATS_NEW": "0"], + arguments: ["CMUX_UITEST_SUPPRESS_WHATS_NEW=1"] + ) + ) + #else + #expect( + UITestConfig.suppressWhatsNewLaunch( + from: ["CMUX_UITEST_SUPPRESS_WHATS_NEW": "1"] + ) == false + ) + #endif + #expect(UITestConfig.suppressWhatsNewLaunch(from: [:]) == false) + #expect( + UITestConfig.suppressWhatsNewLaunch( + from: ["CMUX_UITEST_SUPPRESS_WHATS_NEW": "0"], + arguments: ["CMUX_UITEST_SUPPRESS_WHATS_NEW=0"] + ) == false + ) + #expect( + UITestConfig.suppressWhatsNewLaunch( + from: ["CMUX_UITEST_SUPPRESS_WHATS_NEW": "true"], + arguments: ["CMUX_UITEST_SUPPRESS_WHATS_NEW=10"] + ) == false + ) + } + #if DEBUG @Test(arguments: ["eligible", "ineligible"]) func autoConnectMigrationFixtureRequiresMockDataAndParsesEligibility(_ raw: String) { diff --git a/docs/ci/ios-e2e.md b/docs/ci/ios-e2e.md index f7f63d32c6e7..d2bc611968c8 100644 --- a/docs/ci/ios-e2e.md +++ b/docs/ci/ios-e2e.md @@ -1,127 +1,156 @@ # iOS E2E gate -[.github/workflows/ios-e2e.yml](../../.github/workflows/ios-e2e.yml) proves -the whole Mac-to-iPhone product path on a pull request: a real Mac app on one -runner, a real iOS simulator app on another, the dev web backend on the -durable tailnet VM, then sign-in → pairing → an Iroh connection → a scripted -streamed terminal session. The per-step driver contract and the regression -each step covers live in [scripts/e2e/README.md](../../scripts/e2e/README.md). +[.github/workflows/ios-e2e.yml](../../.github/workflows/ios-e2e.yml) builds the +Mac app and iOS simulator app on one macOS runner. The runner provisions a +tagged web and Postgres backend on the durable GCP VM, signs both apps into the +CI Stack account, pairs them, forces Iroh relay-only transport, and runs the +six-step streamed-terminal driver in +[scripts/e2e/README.md](../../scripts/e2e/README.md). ## Topology ``` - GitHub Actions run - ┌─────────────────────────────────────────────────────────────┐ - │ route (Linux)ci──▶ backend (Linux)ci──────────────┐ │ - │ │ ensure stack ci/ci-main │ - │ ▼ ▼ │ - │ ┌── mac-host (macOS) ──┐ ┌── ios-e2e (macOS) ─┐ - │ │ tagged cmux DEV app │ │ fresh named sim │ - │ │ signed-in, advertised│ │ sign-in, pair │ - │ │ waits on done-file │ │ 6-step terminal │ - │ └───────▲──────────────┘ └──────┬─────────────┘ - └────────────────────│───────────────────────── │─────────────┘ - │ (2) touch done-file │ - │ Tailscale SSH, port 22 │ - tailnet │ tag:ci -> tag:ci │ - ─────────────────────┴──────────────┬───────────┴────────────── - │ (1) HTTPS web API: sign-in, - ▼ pairing ticket, advertise - cmux-dev-backend-1.tail137216.ts.net - (per-tag web + Postgres Docker stacks) - - iOS ⇄ Mac terminal data itself flows over IROH - (relay or direct), never over the tailnet — the - ACL below makes the shortcut impossible. + GitHub Actions run + route (Linux) ------------------------------+ + | + mac-ios-e2e (macOS) + | | + builds Mac + iOS fresh simulator + | | + +-- backend HTTPS/API --+ + | | + Iroh relay-only terminal path | + | | + cmux-dev-backend-1.tail137216.ts.net + per-tag web + Postgres Docker stacks ``` +The Mac and simulator share the runner's operating system. Their terminal +traffic is still forced through Iroh relays, so local networking cannot turn +this into a direct-path test. The backend HTTPS connection uses the private +Tailscale Serve URL for sign-in, pairing tickets, and Mac advertisement. + ## Job graph | Job | Runner | Timeout | Does | | --- | --- | --- | --- | -| `route` | Linux (`blacksmith-4vcpu-ubuntu-2404`) | 5m | Sets `run_e2e=true` for manual dispatch; pull-request runs are skipped, with a backend tag selected for dispatch. | -| `backend` | Linux | 10m | Joins the tailnet (`tailscale/github-action@v4`, tag:ci), pings the backend host, ensures the tagged stack (stub; real call is cmuxterm-hq `scripts/dev-backend.sh url --tag ` over SSH). | -| `mac-host` | macOS (`MACOS_RUNNER_PR` or `blacksmith-6vcpu-macos-26`) | 45m | Downloads the prebuilt Mac app (reuse pending), joins the tailnet under the deterministic name `cmux-e2e-mac-`, launches signed into the CI Stack account, advertises through the backend, waits on `/tmp/e2e-done-` (bounded ~25m). | -| `ios-e2e` | macOS (`MACOS_RUNNER_IOS` fallback chain) | 45m | Downloads the sim app product (pending), boots a fresh per-run simulator, runs `scripts/e2e/ios-e2e-run.sh`, then ALWAYS signals the Mac's done-file over Tailscale SSH, uploads evidence, deletes the sim. | -| `ios-e2e-status` | Linux | 5m | `if: always()` aggregate; the only check to require. | - -`mac-host` and `ios-e2e` both need only `backend` and run in parallel: the -sim's sign-in/pair sequence retries until the Mac is advertised, so -serializing them would just add queue time. - -Teardown is a local done-file touched over Tailscale SSH, never GitHub API -polling from the Mac's wait loop: a ~25-minute per-PR status poll would draw -down the repo-wide API rate limit every workflow shares, and the file needs -no token on the Mac. - -`ios-e2e-status` semantics: green when the route skipped the lane or every -needed job passed; red when the route said run and any needed job failed, was -cancelled, or was skipped unexpectedly; neutral-skip green on fork PRs (the -secret-fenced jobs cannot run there). It writes the route decision and each -job's result to the step summary. +| `route` | Linux | 5m | Chooses `run_e2e` and the backend tag, `ci` for PRs that change `web/`, otherwise `ci-main`. | +| `mac-ios-e2e` | macOS (`MACOS_RUNNER_IOS`, then `MACOS_RUNNER_PR`) | 120m | Joins the tailnet, provisions the GCP stack, builds both apps, boots an isolated simulator, signs in, pairs, verifies relay-only defaults, runs the six steps, uploads evidence, and cleans up. | +| `ios-e2e-status` | Linux | 5m | Always-run aggregate that reports the routed conclusion. | + +The workflow currently has a `workflow_dispatch` trigger. The +`pull_request` trigger stays commented until the owner approves promotion after +the live ACL and one complete run are verified. + +## GCP backend setup + +The persistent VM is `cmux-dev-backend-1` in GCP project `cmux-489202`. It is an +`n2d-standard-32` with Docker, the `devbackendd` control daemon on loopback +port 8477, and the backend's Stack and APNs runtime files installed under +`/srv/cmux-dev`. The VM has the Tailscale tag `tag:dev-backend`. + +The one-time VM installation is owned by the backend administration flow in +cmuxterm-hq: + +```text +./scripts/dev-backend.sh vm-install +./scripts/dev-backend.sh vm-status +``` + +The checked-in cmux CI client does this for each routed run: + +```text +scripts/e2e/gcp-backend.sh start --tag --checkout +scripts/e2e/gcp-backend.sh url --tag +``` + +The CI client archives only `web/` from the PR checkout, sends it to the VM +over Tailscale SSH, asks `devbackendd` to create or update the tagged Docker +web and Postgres stack, and returns a private URL such as +`https://cmux-dev-backend-1.tail137216.ts.net:3916/`. The allocated Serve port +is in the reserved `3800-4799` range. The workflow places that URL in +`CMUX_DEV_BACKEND_URL`, `CMUX_DEV_API_BASE_URL`, and +`CMUX_IROH_BROKER_BASE_URL` before either app is built. The client purges the +run's stack after the E2E step, including its registry entry and source +snapshot. + +The backend's runtime secrets stay on the VM. The CI Stack account is kept on +the runner in `$HOME/.secrets/cmuxterm-dev.env` with mode `0600`, read by the +agent auth profile, and removed in the final cleanup step. + +## Why `CMUX_DEV_BACKEND_SSH_KEY` is unnecessary + +That secret would be a conventional SSH private key for `ubuntu` on the GCP +VM. It was proposed when a Linux backend job was going to call a helper from +cmuxterm-hq. The checked-in CI client runs on the same macOS job as the app +builds and authenticates its SSH connection with the runner's Tailscale node +identity. The Tailscale OAuth client lets the runner join the tailnet as +`tag:ci`; the Tailscale SSH ACL then authorizes `ubuntu` on the backend. The +OAuth client and a VM SSH private key solve different problems. + +There is no `CMUX_DEV_BACKEND_SSH_KEY` reference in the workflow or required +secret list. Removing it avoids another long-lived credential, key rotation, +and a second SSH trust path into the VM. ## Secrets -| Secret | Jobs | Purpose | +| Secret | Job | Purpose | | --- | --- | --- | -| `TS_OAUTH_CLIENT_ID` / `TS_OAUTH_SECRET` | backend, mac-host, ios-e2e | Tailnet OAuth join, tag:ci. | -| `CMUX_DOGFOOD_STACK_EMAIL` / `CMUX_DOGFOOD_STACK_PASSWORD` | mac-host, ios-e2e | Dedicated CI Stack account, same pair as ios-streamed-validate.yml; both ends must resolve one account for pairing's same-account RPC gate. | -| `CMUX_DEV_BACKEND_SSH_KEY` | backend | **Not yet provisioned.** Deploy key for the VM's dev-backend control API, needed by the real ensure call. | +| `TS_OAUTH_CLIENT_ID` / `TS_OAUTH_SECRET` | `mac-ios-e2e` | Join the ephemeral runner to the tailnet with `tag:ci`. | +| `CMUX_DOGFOOD_STACK_EMAIL` / `CMUX_DOGFOOD_STACK_PASSWORD` | `mac-ios-e2e` | CI Stack account used by both app endpoints. The workflow writes these values under the `CMUX_UITEST_*` names required by the `agent` profile. | -Secrets travel only through step environments, never argv, never echoed. -Every secret-mounting job is fenced with -`github.event.pull_request.head.repo.full_name == github.repository`, because -a fork PR controls the workflow file's own content; the aggregate reports -forks as a neutral skip instead of a red. +Secrets travel through step environments and the mode `0600` credentials file. +They are never passed as account values on a command line or written to the +repository checkout. ## Tailscale ACL requirements -- `tag:ci` → `cmux-dev-backend-1.tail137216.ts.net` on 443 (Tailscale Serve - web API for sign-in/pairing/advertise) and 22 (dev-backend control SSH, - once the ensure call lands). -- `tag:ci` → `tag:ci` on port 22 ONLY (Tailscale SSH, for the done-file - signal), with an SSH rule mapping to the runner login user. +The current direct backend helper returns an HTTPS URL on the VM's allocated +Serve port. Configure both network access and Tailscale SSH: + +- Network ACL: `tag:ci` to `tag:dev-backend` on TCP `22` and TCP + `3800-4799`. +- Tailscale SSH policy: accept `tag:ci` to `tag:dev-backend` for user + `ubuntu`. +- No `tag:ci` to `tag:ci` rule is needed. The Mac and simulator are on one + runner and do not signal each other over SSH. + +An ACL that allows only ports `443` and `22` does not cover the current direct +Serve URLs because they include an allocated port such as `3916`. Add the +reserved range or change the helper and workflow together to use a fixed +443 endpoint. + +The relay-only setting is enforced in both app configurations: + +- Mac defaults: `cmux.iroh.debug.transport-mode=relayOnly` and + `cmux.iroh.v2.force-relay=true`. +- iOS simulator defaults: `cmux.iroh.debug.transport-mode=relayOnly` and + `cmux.iroh.v2.config.CMUX_IROH_V2_FORCE_RELAY=1`. -The narrowness of the second rule is load-bearing: if runners could reach -each other on arbitrary ports, Iroh's path probing could discover the -runners' Tailscale IPs and carry the terminal stream host-to-host over the -tailnet. The run would go green while testing a transport path no customer -has, which is exactly the false confidence this lane exists to eliminate. -Port 22 alone is useless to Iroh and sufficient for one `touch`. +The workflow reads both values back before pairing and after launch. The E2E +driver also asserts the tagged Mac socket and simulator rendering for every +terminal step. ## Infra-preflight failure labeling -Steps that can only fail for infrastructure reasons — tailnet join, backend -ping/ensure, product downloads, simulator boot, the teardown signal — emit -errors prefixed `[infra-preflight]`. Triage rule: an `[infra-preflight]` red -is a fleet/ACL/cache problem for CI infra, never a product regression, and it -does not count against the lane's flake budget during shadow. A red with no -`[infra-preflight]` marker is the E2E itself and gets a -`E2E FAIL step=` line naming the failed step -(see [scripts/e2e/README.md](../../scripts/e2e/README.md)). +Tailnet join, backend reachability, backend serving, missing runner tools, +simulator boot, and cleanup failures emit `[infra-preflight]` when the failure +is outside the product path. A driver failure ends with `E2E FAIL step=` +and is treated as a product-path result. Evidence is uploaded with seven-day +retention on every run. ## Promotion plan -1. **Shadow.** The workflow runs on manual dispatch while pull-request runs are skipped, and - on dispatch. Expected red until the TODOs land, in this order: real - router via `scripts/ci/detect_ci_change_areas.py`; backend ensure with - `CMUX_DEV_BACKEND_SSH_KEY`; Mac app product reuse - (`scripts/ci/reuse_app_host_products.py` consumer path); iOS sim product - reuse (test-ios.yml's `ios-test-product-*` artifact); the two driver - scripts. During shadow, track pass rate and `[infra-preflight]` rate - separately. -2. **Required inside the ios aggregate.** Once the lane holds a stable pass - rate with infra-preflight reds at fleet-noise level, `ios-e2e-status` - joins the required iOS aggregate check rather than becoming its own - branch-protection entry, keeping one required conclusion per area. The - neutral-skip semantics (route skip, fork PRs) already match what a - required check needs. - -Dictionary: **aggregate** — the single always-run job whose conclusion -branch protection requires on behalf of a lane's many conditional jobs; -**shadow** — running a check on every PR without requiring it, to measure -reliability before it can block merges; **done-file** — the local file whose -appearance releases the Mac host's bounded wait, our GitHub-API-free -teardown handshake; **infra-preflight** — a labeled failure in environment -setup (tailnet, cache, backend, simulator) as opposed to the product path -under test. +1. Run the workflow manually with the final ACL and confirm one complete + relay-only pass, including the uploaded evidence artifact. +2. Integrate `scripts/ci/detect_ci_change_areas.py` into `route` so unrelated + changes skip the expensive Mac job while `ios-e2e-status` still concludes. +3. Enable the `pull_request` trigger and require `ios-e2e-status` after the + lane has a stable pass rate and infrastructure failures are understood. + +Dictionary: **Tailscale SSH** means SSH authorization supplied by the +Tailscale ACL and node identity instead of a VM private key; **Serve** means a +tailnet-only HTTPS listener forwarding to a VM-local service; **relay-only** +means Iroh is prevented from selecting a direct peer path; **aggregate** means +the one always-run check that represents conditional jobs; **shadow** means a +check runs for measurement before branch protection requires it. diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 2a5bf7be523c..e7a77829cbe3 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -380,6 +380,14 @@ public struct CMUXMobileRootScene: View { .environment(whatsNewCenter) .environment(macCompatCenter) .environment(\.mobileWebAppSession, webAppSession) + #if DEBUG + .environment( + \.mobileWhatsNewPresentationPolicy, + MobileWhatsNewPresentationPolicy( + suppressLaunchPresentation: UITestConfig.suppressWhatsNewLaunch + ) + ) + #endif #endif } diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 671612a4fcfa..c27f49fdc1ea 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -1,82 +1,70 @@ -# scripts/e2e — iOS E2E drivers +# scripts/e2e - iOS E2E drivers Driver contract for [.github/workflows/ios-e2e.yml](../../.github/workflows/ios-e2e.yml). -The workflow owns runner selection, tailnet join, product download, the -backend stack, evidence upload, and the teardown signal; these scripts own -everything on the runner between "app product on disk" and "verdict". The -interface is environment variables only, no flags — keep it stable, the -workflow and the scripts land from different PRs. +The workflow owns runner selection, tailnet join, GCP backend provisioning, +both app builds, relay-only configuration, simulator lifecycle, evidence +upload, and cleanup. `ios-e2e-run.sh` owns the six terminal steps after the +apps are signed in, paired, and connected. -## mac-host.sh +## ios-e2e-run.sh -Launches the tagged Mac app, signs it into the CI Stack account, advertises it -through the dev backend so the iOS client can discover and pair with it, then -blocks until the iOS job signals completion. +Drives an already signed-in, paired, connected simulator through the six-step +terminal script against a real streamed terminal. The workflow owns sign-in, +pairing, and connection setup; the driver receives the run identity and +simulator explicitly through flags. -| Env | Meaning | +| Flag | Meaning | | --- | --- | -| `CMUX_E2E_TAG` | Shared dev tag for this run (`ci` or `ci-main`). Names the app bundle (`com.cmuxterm.app.debug.`), the debug socket (`/tmp/cmux-debug-.sock`), and the backend stack. | -| `CMUX_DEV_BACKEND_URL` | Web API origin of the ensured backend stack (private Tailscale Serve URL on the durable VM). | -| `CMUX_E2E_DONE_FILE` | Absolute path of the teardown file. Poll for it locally (sleep loop); the iOS job touches it over Tailscale SSH. Never substitute GitHub API status polling — a ~25-minute per-PR poll loop draws down the repo-wide API rate limit, and the file needs no token. | -| `CMUX_E2E_WAIT_TIMEOUT_SECONDS` | Optional bound on the done-file wait; default 1500 (~25m). Expiry exits 0 as an infrastructure timeout. | -| `CMUX_DOGFOOD_STACK_EMAIL` / `CMUX_DOGFOOD_STACK_PASSWORD` | Dedicated CI Stack account (the pair ios-streamed-validate.yml uses; the app's dev-secrets resolution reads `CMUX_DOGFOOD_STACK_*` from the environment first). Never echo, never pass on argv, never write to disk. | - -Exit 0 means the app launched, signed in, advertised, and the done-file -appeared in time. On failure exit nonzero and name the phase on the last -stderr line: `launch`, `sign-in`, `advertise`, or `wait-timeout`. - -## ios-e2e-run.sh - -Signs the simulator app in, pairs it to the remote Mac through the backend, -connects over Iroh, and drives the 6-step terminal script against a real -streamed terminal. +| `--tag ` | Shared Mac/iOS dev tag; pairing is tag-scoped. | +| `--sim-udid ` | Exact booted simulator owned by this run. The driver passes this UDID to every simctl call. | +| `--evidence-dir ` | Directory for screenshots, streamed-grid text dumps, and device logs. The workflow uploads it verbatim (`if: always()`). | +| `--bundle-id ` | Optional installed bundle override. Without it, the driver discovers the isolated `dev.cmux.*` bundle on the simulator. | +| `--step-timeout ` | Optional bounded wait per terminal step; default 45 seconds. | | Env | Meaning | | --- | --- | -| `CMUX_E2E_TAG` | Same shared tag as the Mac host (bundle `dev.cmux.ios.`); pairing is tag-scoped, so a tag mismatch can never pair. | -| `CMUX_DEV_BACKEND_URL` | Web API origin used for sign-in and pairing. | -| `CMUX_E2E_SIM_UDID` | The freshly created, booted simulator this run owns. Pass it to every simctl/idb call; never resolve by name. | -| `CMUX_E2E_EVIDENCE_DIR` | Directory for screenshots, streamed-grid text dumps, and device logs; the workflow uploads it verbatim (`if: always()`). Write a capture at every step boundary, pass or fail. | -| `CMUX_DOGFOOD_STACK_EMAIL` / `CMUX_DOGFOOD_STACK_PASSWORD` | Same account as the Mac host — pairing's same-account RPC gate requires both ends to resolve one account. Same secrecy rules. | +| `CMUX_DEV_BACKEND_URL` | Web API origin used for sign-in and pairing. The workflow obtains it from `scripts/e2e/gcp-backend.sh url --tag`. | +| `CMUX_IROH_BROKER_BASE_URL` | Same backend origin baked into both app builds for broker discovery. | +| `CMUX_DOGFOOD_STACK_EMAIL` / `CMUX_DOGFOOD_STACK_PASSWORD` | Same account used by the Mac and simulator. The workflow stores it as `CMUX_UITEST_*` in a mode `0600` file for the `agent` profile. | + +The workflow sets `CMUX_IROH_V2_FORCE_RELAY=1` for the Mac build and writes the +relay-only defaults for both installed app bundles before +`mobile-dev-launch.sh` starts the simulator. The driver assumes that policy is +already configured; it does not switch transport modes during a step. -On failure exit nonzero and print `E2E FAIL step=` as the last stderr -line, where `` is a step id below or `sign-in`, `pair`, `connect` for the -setup phases. +On failure exit nonzero and print `E2E FAIL step=` as the last stderr line, +where `` is a step id below or `sign-in`, `pair`, `connect` for setup. -### The 6-step terminal script +### The six-step terminal script Each step covers a shipped regression; do not weaken a step without replacing its coverage. -1. `marker-1` — type `echo E2E--A` into the streamed terminal and assert +1. `marker-1` - type `echo E2E--A` into the streamed terminal and assert the echoed marker renders in the grid within a bounded wait. Proves the - full live keystroke path: iOS key → Iroh → Mac PTY → stream → grid. + full live keystroke path: iOS key -> Iroh -> Mac PTY -> stream -> grid. Regression: input echo stall, caught only by marker-echo liveness ([#12927](https://github.com/manaflow-ai/cmux/pull/12927)). -2. `burst-scrollback` — run `seq 1 5000`, wait for the tail, scroll back and +2. `burst-scrollback` - run `seq 1 5000`, wait for the tail, scroll back and assert an early line and the final line are both intact. Proves ordered byte-tee append and scrollback integrity under burst output. - Regression: O(chunk²) byte-tee append and viewport livelock + Regression: O(chunk^2) byte-tee append and viewport livelock ([#13432](https://github.com/manaflow-ai/cmux/pull/13432)). -3. `alt-screen` — open `less` on a real file, assert the alt-screen UI - rendered, quit with `q`, assert the primary screen (step 2's tail) is - restored. Proves the atomic alt-screen swap both directions. +3. `alt-screen` - open `less` on a real file, assert the alt-screen UI + rendered, quit with `q`, and assert the primary screen is restored. Regression: alt-screen transition freeze ([#12844](https://github.com/manaflow-ai/cmux/pull/12844)). -4. `interrupt` — start `sleep 300`, send Ctrl-C, assert the prompt returns. - Proves control-byte delivery works independently of the output path; an - interrupt that only lands on an idle stream is broken. -5. `replay` — background the iOS app (or drop the connection), generate - output on the Mac side, foreground, and assert the reconnected grid - replays the missed content rather than staying blank. - Regression: black-holed QUIC path kept installed, terminal blank on replay - ([#14030](https://github.com/manaflow-ai/cmux/pull/14030)). -6. `marker-2` — type `echo E2E--B` and assert it echoes. Proves the - session is still live for INPUT after the churn of steps 2–5: reconnect - and recovery must not have wedged the transport behind a cooldown. - Regression: pre-bootstrap recovery armed a cooldown that filtered Iroh - and stalled the fresh session +4. `interrupt` - start `sleep 30`, send Ctrl-C, and assert the prompt returns. + Proves control-byte delivery independently of the output path. +5. `replay` - background the iOS app, relaunch it, and assert the reconnected + grid replays the missed content rather than staying blank. + Regression: a black-holed QUIC path kept installed and left the terminal + blank on replay ([#14030](https://github.com/manaflow-ai/cmux/pull/14030)). +6. `marker-2` - type `echo E2E--B` and assert it echoes. Proves the + session is still live for input after reconnect and recovery. + Regression: a pre-bootstrap recovery cooldown stalled a fresh session ([#14124](https://github.com/manaflow-ai/cmux/pull/14124)). -The workflow — not this script — signals the Mac host's done-file over -Tailscale SSH after this script exits, pass or fail. +The workflow stops the tagged Mac app, deletes the isolated simulator, removes +the tagged backend stack, and deletes the temporary credentials file after the +driver exits, including on failure. diff --git a/scripts/e2e/backend-env.sh b/scripts/e2e/backend-env.sh new file mode 100755 index 000000000000..836470cb10c2 --- /dev/null +++ b/scripts/e2e/backend-env.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# App-side half of the per-run backend (scripts/e2e/backend-up.sh): prints the +# environment that points a tagged Mac or iOS simulator app at this run's +# origins instead of shared staging/development, then optionally waits until +# the backend runner is serving them. +# +# Usage: backend-env.sh env [--simctl] KEY=VALUE lines for $GITHUB_ENV; +# --simctl also emits SIMCTL_CHILD_* +# copies for apps launched by simctl +# backend-env.sh wait [seconds] bounded poll until all three origins +# answer (default 1200: covers a cold +# web build on the backend runner) +# +# The Mac reads these from its process environment only when launched +# directly (scripts/e2e/mac-host.sh execs the binary): LaunchServices applies +# a baked LSEnvironment on `open`, and reload.sh bakes staging origins there. +# iOS reads CMUX_IROH_V2_* and CMUX_PRESENCE_BASE_URL from SIMCTL_CHILD_* at +# launch; its general API origin is Info.plist-only (CMUXApiBaseURL), so the +# sim product step must stamp it (docs/ci/ios-e2e.md#per-run-backend). +set -euo pipefail + +FQDN="${CMUX_E2E_BACKEND_FQDN:?CMUX_E2E_BACKEND_FQDN is required}" +WEB="https://$FQDN" +IROH_V2="https://$FQDN:8443" +PRESENCE="https://$FQDN:10000" + +emit_env() { + local simctl="${1:-}" line + local lines=( + "CMUX_IROH_V2_BASE_URL=$IROH_V2" + "CMUX_IROH_V2_ENVIRONMENT=development" + # The lane gates the managed relay path; direct routes between the two + # runners are also closed by the tailnet ACL. + "CMUX_IROH_V2_FORCE_RELAY=1" + "CMUX_PRESENCE_BASE_URL=$PRESENCE" + "CMUX_API_BASE_URL=$WEB" + "CMUX_VM_API_BASE_URL=$WEB" + "CMUX_WWW_ORIGIN=$WEB" + "CMUX_AUTH_WWW_ORIGIN=$WEB" + "CMUX_DEVICE_REGISTRY_API_BASE_URL=$WEB" + "CMUX_PUSH_API_BASE_URL=$WEB" + "CMUX_IROH_BROKER_BASE_URL=$WEB" + "CMUX_DEV_BACKEND_URL=$WEB" + ) + for line in "${lines[@]}"; do + echo "$line" + [[ "$simctl" == "--simctl" ]] && echo "SIMCTL_CHILD_$line" + done + return 0 +} + +wait_ready() { + local budget="${1:-1200}" deadline url + deadline=$(( $(date +%s) + budget )) + for url in "$IROH_V2/v2/health" "$PRESENCE/healthz" "$WEB/"; do + until curl -fsS -o /dev/null --max-time 5 "$url"; do + if (( $(date +%s) >= deadline )); then + echo "::error::[infra-preflight] per-run backend never served $url (backend job log has the cause)" >&2 + exit 1 + fi + sleep 3 + done + echo "[backend-env] ready: $url" + done +} + +case "${1:-}" in + env) emit_env "${2:-}" ;; + wait) wait_ready "${2:-1200}" ;; + *) echo "usage: $0 env [--simctl] | wait [seconds]" >&2; exit 2 ;; +esac diff --git a/scripts/e2e/backend-up.sh b/scripts/e2e/backend-up.sh new file mode 100755 index 000000000000..2885c7f70957 --- /dev/null +++ b/scripts/e2e/backend-up.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# Per-run backend for the iOS e2e lane, on the Linux runner that hosts it. +# +# One fresh, isolated copy of everything the Mac<->iPhone path calls, so a run +# never shares state with another run, an agent's dev stack, or staging: +# - workers/iroh-v2 (TeamControl/UserUsage Durable Objects: API tickets, +# device registration, directory/advertise, relay +# credentials) -> https://$FQDN:8443 +# - workers/presence (TeamPresence/AccountControlPlane/WorkspacePresence +# Durable Objects) -> https://$FQDN:10000 +# - web/ (Next.js: device registry, push, legacy broker, +# general API) -> https://$FQDN +# - Postgres 16 (web's database plus the iroh-v2 ownership tables) +# Stack Auth and the managed relays stay shared: sign-in is the CI account in +# the dev Stack project, and the relays only trust the dev v2 signing key. +# +# Both Workers run in local workerd (`wrangler dev`), so Durable Object state +# starts empty every run and nothing is deployed to Cloudflare. Tailscale Serve +# publishes the three HTTPS origins on the runner's tailnet name with a public +# certificate, which the apps require (https-only origins, iOS ATS). +# +# iroh-v2 connects to Postgres with rejectUnauthorized TLS. Postgres therefore +# serves the same `tailscale cert` certificate and listens on the runner's own +# tailnet address, so the Worker's connection verifies against a public CA +# without any test-only TLS switch in product code. The tailnet ACL does not +# expose 5432 to other nodes. +# +# Usage: backend-up.sh up start and health-check everything, then return +# backend-up.sh hold block until CMUX_E2E_BACKEND_DONE_FILE appears +# or CMUX_E2E_WAIT_TIMEOUT_SECONDS expires +# +# Env contract (docs/ci/ios-e2e.md#per-run-backend): +# CMUX_E2E_BACKEND_FQDN tailnet name this runner joined with +# CMUX_E2E_BACKEND_STATE_DIR scratch dir (default $RUNNER_TEMP/e2e-backend) +# CMUX_E2E_STACK_PROJECT_ID dev Stack project (same as the CI account) +# CMUX_E2E_STACK_PUBLISHABLE_KEY +# CMUX_E2E_STACK_SERVER_KEY +# CMUX_E2E_RELAY_SIGNING_KEY dev v2 relay EdDSA key (PEM) the relays trust +# CMUX_E2E_RELAY_KEY_ID +# CMUX_E2E_SKIP_WEB_BUILD=1 .next was restored for this exact web/ tree +# Failures print [infra-preflight] because nothing here is the product path +# under test (docs/ci/ios-e2e.md#infra-preflight-failure-labeling). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +FQDN="${CMUX_E2E_BACKEND_FQDN:?CMUX_E2E_BACKEND_FQDN is required}" +STATE="${CMUX_E2E_BACKEND_STATE_DIR:-${RUNNER_TEMP:-/tmp}/e2e-backend}" +LOGS="$STATE/logs" + +WEB_PORT=3000 +IROH_V2_PORT=8787 +PRESENCE_PORT=8788 +PG_CONTAINER=cmux-e2e-postgres + +phase() { echo "[backend:$1] $2"; } +die() { + echo "::error::[infra-preflight] backend $1: $2" >&2 + for log in "$LOGS"/*.log; do + [[ -f "$log" ]] || continue + echo "--- tail $log" >&2 + tail -n 40 "$log" >&2 || true + done + exit 1 +} + +# wait_http