diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 80ab5f1c8b1c..ea5d86acc475 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -1,17 +1,12 @@ -name: iOS TestFlight (beta) +name: iOS TestFlight (CMUX INTERNAL) on: - # No push trigger. A TestFlight upload is a release action: you only ever want - # the LATEST main state in beta, exactly once per change, never one upload per - # commit. Triggering on every iOS-affecting push forced a concurrency group to - # dedup concurrent uploads, and GitHub cancels the superseded *pending* runs in - # that group during merge bursts; those cancelled runs surface as red checks on - # the intermediate main commits, making main look like CI is failing. The - # schedule below already SHA-compares HEAD to the last uploaded commit (the - # `decide` job), which is the correct primitive for a beta lane: it uploads the - # current main only when it has actually advanced, and skips (green) otherwise. - # For an immediate beta, use workflow_dispatch; intentional cuts go through the - # release flow. See nightly.yml for the rolling dogfood lane. + # Every main commit gets its own cmux INTERNAL build. Do not add a path filter: + # the contract is one TestFlight upload for every main change, regardless of + # which part of the repository changed. + push: + branches: + - main workflow_dispatch: inputs: build_number: @@ -22,40 +17,12 @@ on: description: Optional one-time MARKETING_VERSION override (for example 1.0.1) required: false default: "" - force: - # Manual (workflow_dispatch) runs always upload, so this only documents - # intent. It exists so the no-new-commits skip can be bypassed if the - # 24h commit-window check is ever extended to dispatch runs. - description: Force an upload (manual runs already always upload) - required: false - default: false - type: boolean - schedule: - # Every ~2h (at :17 to stay off the top-of-hour rush). The decide job skips a - # run when the current main HEAD was already uploaded by a prior successful - # run (SHA compare, not a wall-clock window), and also when main advanced but - # the diff since the last uploaded beta touches no iOS-affecting path (every - # upload notifies every TestFlight tester, so web-only / macOS-only merges - # must not ship a new beta). An iOS-affecting change still reaches the - # TestFlight beta lane within ~2h of landing on main, and a failed or missed - # run retries the not-yet-uploaded commit instead of permanently stranding - # it. These uploads are external-eligible too, and reuse - # CMUX_IOS_BETA_MARKETING_VERSION so external testers receive new builds under - # the already-approved beta version until that version is intentionally bumped. - # - # Why ~2h and not a push trigger / per-commit: a per-push lane needs either a - # shared concurrency group (which cancels superseded pending runs into red - # checks) or per-SHA concurrency (which removes the serialization that keeps - # parallel archives from racing on the timestamp build number). ~2h spacing - # keeps runs from overlapping (an archive+upload takes ~30-60m), so this - # single per-ref lane stays serialized and uploads never collide. If faster - # turnaround is ever needed, tighten the interval (still > one archive's - # duration) rather than adding a push trigger. - - cron: "17 */2 * * *" concurrency: - group: ios-testflight-${{ github.ref_name }} - # Queue concurrent runs instead of canceling them so no upload is lost. + # A shared group keeps only one pending run and silently replaces older pending + # SHAs during merge bursts. Key push runs by SHA so every main commit survives. + # Manual runs use run_id because an operator may intentionally rebuild a SHA. + group: ios-testflight-${{ github.event_name == 'push' && github.sha || github.run_id }} cancel-in-progress: false permissions: @@ -65,46 +32,100 @@ permissions: jobs: decide: - name: Decide whether a TestFlight upload is needed + name: Order main uploads and resolve prior build runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} - timeout-minutes: 5 + timeout-minutes: 360 outputs: should_build: ${{ steps.decide.outputs.should_build }} - should_assign_only: ${{ steps.decide.outputs.should_assign_only }} last_uploaded_sha: ${{ steps.decide.outputs.last_uploaded_sha }} - last_uploaded_run_id: ${{ steps.decide.outputs.last_uploaded_run_id }} steps: - name: Decide whether a TestFlight upload is needed id: decide uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }} with: script: | - const forceBuild = process.env.FORCE_BUILD === 'true'; const { owner, repo } = context.repo; - // The head_sha of the most recent CANONICAL run on main whose *upload - // job* succeeded is the last uploaded beta commit. We intentionally do - // NOT key this off whole-workflow success: a later post-upload job (for - // example external-group assignment) may fail after the IPA has already - // been uploaded, and re-uploading the same SHA on the next schedule - // would create duplicate TestFlight builds for one commit. We always - // resolve this SHA so the schedule SHA-compare can skip an already- - // uploaded HEAD, AND the upload job can use it as the base of the - // "What to Test" commit range. branch:'main' is required: the upload - // job is gated on github.ref == 'refs/heads/main', so a - // workflow_dispatch run on a feature branch can succeed without - // uploading anything. Without this filter its branch SHA would become - // last_uploaded_sha and poison the next real beta's notes base (the - // generator also fails closed to a fallback line when the base is not - // an ancestor of HEAD). + // Per-SHA workflow concurrency preserves every push, but it also lets + // several runs reach App Store Connect at once. Build numbers must be + // uploaded monotonically, so wait on a cheap Linux runner until every + // earlier main-push run has finished its upload job. Assignment can + // continue independently after the upload completes. + if (context.ref === 'refs/heads/main') { + const currentRunId = Number(context.runId); + const maxWaits = 300; + for (let wait = 0; wait < maxWaits; wait += 1) { + const blockingRuns = []; + try { + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'ios-testflight.yml', + branch: 'main', + per_page: 100, + }); + const earlierActiveRuns = runs.data.workflow_runs.filter( + (run) => + Number(run.id) < currentRunId && + run.status !== 'completed' && + ['push', 'workflow_dispatch'].includes(run.event) + ); + for (const run of earlierActiveRuns) { + const jobs = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const uploadJob = jobs.data.jobs.find( + (job) => job.name === 'Upload to TestFlight' + ); + if (!uploadJob || uploadJob.status !== 'completed') { + blockingRuns.push(run.id); + } + } + } catch (error) { + if (wait === maxWaits - 1) { + core.setFailed( + `timed out ordering main TestFlight uploads after GitHub API errors: ${error.message}` + ); + return; + } + if (wait % 5 === 0) { + core.warning( + `could not inspect earlier TestFlight runs; retrying: ${error.message}` + ); + } + await new Promise((resolve) => setTimeout(resolve, 60_000)); + continue; + } + if (blockingRuns.length === 0) break; + if (wait === maxWaits - 1) { + core.setFailed( + `timed out waiting for earlier main TestFlight upload run(s): ${blockingRuns.join(', ')}` + ); + return; + } + if (wait % 5 === 0) { + core.info( + `waiting for earlier main TestFlight upload run(s): ${blockingRuns.join(', ')}` + ); + } + await new Promise((resolve) => setTimeout(resolve, 60_000)); + } + } + + // Resolve the most recent successful canonical upload as the base for + // this build's "What to Test" commit range. Key off the upload job, + // not whole-workflow success, because internal-group assignment happens + // after the IPA is already in App Store Connect. Restrict the lookup to + // main so a blocked feature-branch dispatch cannot poison the notes base. // // Manual marketing-version-override uploads are deliberately excluded // from this canonical lane. They ship the current main SHA under an // operator-selected beta marketing version as a one-off escape hatch, but - // they must NOT suppress the next scheduled canonical beta for the - // same commit. Override runs upload a dedicated + // they must not become the notes base for the next canonical main + // upload. Override runs upload a dedicated // ios-testflight-build-metadata-override artifact instead of the // canonical ios-testflight-build-metadata artifact, and we skip only // those runs here. @@ -115,10 +136,6 @@ jobs: // is necessarily a normal immediate beta cut and SHOULD count as the // last canonical upload. let lastUploadedSha = null; - let lastUploadedRunId = null; - let lastAssignmentSucceeded = false; - let lastAssignmentRetrySupported = false; - let lookupFailed = false; try { for (let page = 1; page <= 20 && !lastUploadedSha; page += 1) { const runs = await github.rest.actions.listWorkflowRuns({ @@ -130,7 +147,7 @@ jobs: page, }); for (const run of runs.data.workflow_runs) { - if (run.id === context.runId || run.status !== 'completed') continue; + if (run.id === context.runId) continue; const jobs = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, @@ -155,166 +172,35 @@ jobs: continue; } lastUploadedSha = run.head_sha; - lastUploadedRunId = String(run.id); - const assignJob = jobs.data.jobs.find( - (job) => - job.name === 'Assign build to internal TestFlight group' || - job.name === 'Assign build to external TestFlight group' - ); - // Older successful upload runs predate the external-assignment - // job and metadata artifact entirely. Those runs uploaded the - // current main SHA, but they are NOT safe to treat as - // assign-only retry candidates because there is no artifact to - // download and the build may not even be external-eligible. - // Only the post-migration workflow shape can enter the - // assignment-only path. - lastAssignmentRetrySupported = !!assignJob; - // A same-version sibling already in Beta App Review is a - // legitimate "pending" state outside CI's control, so the - // assign job returns success but uploads a dedicated pending - // artifact. That lets the schedule retry assignment-only - // later without turning the current main commit red. - // - // Fail closed for RECENT pre-migration success runs that - // have NO assignment-state artifact at all. The old helper - // could report success both when the build was truly - // complete and when it was merely pending behind a sibling - // review, so a fresh missing-state run should be retried; a - // genuinely-complete build will short-circuit quickly on - // the recheck. - // - // Do NOT fail closed forever: artifacts expire after 30 - // days, and an idle main branch must not fall into - // permanent red assign-only retries just because historical - // metadata aged out. Once the run is past the retention - // horizon, treat missing state as effectively complete. - const assignmentComplete = - artifactNames.has('ios-testflight-assignment-state-complete'); - const assignmentPending = - artifactNames.has('ios-testflight-assignment-state-pending'); - const runAgeMs = Date.now() - Date.parse(run.created_at); - const assignmentArtifactRetentionMs = 30 * 24 * 60 * 60 * 1000; - const assignmentStateExpired = runAgeMs > assignmentArtifactRetentionMs; - lastAssignmentSucceeded = - assignJob?.conclusion === 'success' && - (assignmentComplete || (!assignmentPending && assignmentStateExpired)); break; } } if (runs.data.workflow_runs.length < 100) break; } } catch (e) { - lookupFailed = true; core.warning(`could not resolve last uploaded sha: ${e.message}`); } - // A scheduled run must FAIL CLOSED when the history lookup ERRORED: a - // null last-uploaded SHA reads as "not HEAD" below, so the lane would - // re-upload the same already-shipped main commit every 2h with a fresh - // build number and fallback notes. Before this job wrapped the lookup in - // try/catch the throw failed the job here; preserve that. A genuine - // no-prior-run (the API SUCCEEDED but returned no runs) is NOT a failure: - // lookupFailed stays false and the first beta builds normally. - if (context.eventName === 'schedule' && lookupFailed) { - core.setFailed('could not resolve the last uploaded beta SHA (workflow run history lookup failed); refusing to auto-upload to avoid duplicate TestFlight builds'); - return; - } - - // workflow_dispatch always builds (the operator asked for it). - // Scheduled runs build unless HEAD was ALREADY uploaded by a prior - // successful run (a SHA compare, not a wall-clock window: a failed or - // missed run leaves the last success on an older SHA, so the next run - // retries the un-uploaded commit instead of stranding it). - let needsBuild = true; - if (!forceBuild && context.eventName === 'schedule') { - needsBuild = lastUploadedSha !== context.sha; - } - - // Path gate: even when main advanced, a scheduled beta only ships if - // the range since the last uploaded beta touches something that can - // change the iOS IPA. Every TestFlight upload notifies every tester, - // so web-only / macOS-only / docs-only merges must not produce a new - // build. The path set mirrors test-ios.yml's should_run gate plus the - // inputs the archive actually consumes: the ghostty submodule pointer - // (GhosttyKit is linked into the app), the GhosttyKit provisioning - // scripts, the zig toolchain pin, and this workflow itself (archive / - // export settings live here). This gate FAILS OPEN (builds anyway) on - // any doubt - a compare-API error, a diverged range, a >=300-file - // diff where the API truncates the file list - because a spurious - // upload costs one notification while a wrong skip silently strands - // an iOS change out of beta. - const iosPathPattern = /^(ios\/|Packages\/Shared\/|Packages\/iOS\/|Sources\/Mobile\/|vendor\/stack-auth-swift-sdk-prerelease\/|ghostty$|scripts\/ensure-ghosttykit\.sh$|scripts\/ghosttykit-checksums\.txt$|scripts\/install-zig-ci\.sh$|\.github\/workflows\/ios-testflight\.yml$)/; - let iosPathsChanged = 'not-evaluated'; - if (!forceBuild && context.eventName === 'schedule' && needsBuild && lastUploadedSha) { - try { - const compare = await github.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${lastUploadedSha}...${context.sha}`, - }); - const files = compare.data.files || []; - if (compare.data.status !== 'ahead') { - core.warning(`compare status is '${compare.data.status}', not 'ahead'; assuming iOS paths changed`); - iosPathsChanged = 'assumed (non-linear history)'; - } else if (files.length >= 300) { - // The compare API caps the files list at 300 entries. - core.warning('diff since last upload has >=300 files (API truncates); assuming iOS paths changed'); - iosPathsChanged = 'assumed (diff too large)'; - } else { - const changed = files.some( - (f) => - iosPathPattern.test(f.filename) || - (f.previous_filename && iosPathPattern.test(f.previous_filename)) - ); - iosPathsChanged = String(changed); - if (!changed) { - needsBuild = false; - core.notice(`skipping TestFlight upload: ${files.length} changed file(s) since ${lastUploadedSha} touch no iOS-affecting path`); - } - } - } catch (e) { - core.warning(`could not diff ${lastUploadedSha}...${context.sha}; assuming iOS paths changed: ${e.message}`); - iosPathsChanged = 'assumed (compare failed)'; - } - } - - // Retry a not-yet-complete external-group assignment whenever this - // run is NOT building: both when HEAD was already uploaded and when - // the path gate skipped a non-iOS range (the previously uploaded - // build is still the current beta and its assignment must not be - // stranded until the next iOS change lands). - const shouldAssignOnly = - !forceBuild && - context.eventName === 'schedule' && - !needsBuild && - lastAssignmentRetrySupported && - !lastAssignmentSucceeded; - + // Push and manual runs always build. There is deliberately no SHA or + // path gate: every main push owns one CMUX INTERNAL upload. const shouldBuild = - forceBuild || context.eventName === 'workflow_dispatch' || needsBuild; + context.eventName === 'push' || context.eventName === 'workflow_dispatch'; core.setOutput('should_build', shouldBuild ? 'true' : 'false'); - core.setOutput('should_assign_only', shouldAssignOnly ? 'true' : 'false'); core.setOutput('last_uploaded_sha', lastUploadedSha || ''); - core.setOutput('last_uploaded_run_id', lastUploadedRunId || ''); core.summary .addHeading('iOS TestFlight upload decision') .addTable([ [{ data: 'event', header: true }, context.eventName], - [{ data: 'force', header: true }, String(forceBuild)], [{ data: 'head sha', header: true }, context.sha], - [{ data: 'last uploaded sha (schedule only)', header: true }, String(lastUploadedSha)], - [{ data: 'last uploaded run id', header: true }, String(lastUploadedRunId)], - [{ data: 'ios paths changed since last upload', header: true }, iosPathsChanged], - [{ data: 'last external assignment succeeded', header: true }, String(lastAssignmentSucceeded)], + [{ data: 'last uploaded sha (notes base)', header: true }, String(lastUploadedSha)], [{ data: 'should build', header: true }, String(shouldBuild)], - [{ data: 'should assign only', header: true }, String(shouldAssignOnly)], ]) .write(); upload: name: Upload to TestFlight needs: decide - # Only ever publish from main. push/schedule always run on main; this also + # Only ever publish from main. Push runs always use main; this also # blocks publishing arbitrary code by dispatching the workflow against a # feature branch (the ASC secrets are only meant to ship reviewed main). if: needs.decide.outputs.should_build == 'true' && github.ref == 'refs/heads/main' @@ -483,7 +369,7 @@ jobs: id: upload env: # Only set for manual workflow_dispatch with an explicit build number. - # For push/schedule this is empty and the script generates a monotonic + # For push this is empty and the script generates a monotonic # 14-digit UTC timestamp itself (single source of the numbering scheme, # so the workflow can't drift from it the way it did before). An empty # value here means "let the script decide", which also keeps the guard's @@ -500,7 +386,7 @@ jobs: # Optional manual one-off override that reuses an older approved beta # marketing version so external testers can install it immediately. INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }} - # Display name for scheduled internal builds (auto-synced to internal group). + # Display name for automatic internal builds (auto-synced to internal group). IOS_BETA_DISPLAY_NAME: cmux INTERNAL # Internal builds use separate bundle ID (dev.cmux.app.internal) so internal # and external can coexist on same device. @@ -525,8 +411,8 @@ jobs: --marketing-version "$INPUT_MARKETING_VERSION_OVERRIDE" \ --skip-notes else - # Reuse the checked-in CMUX_IOS_BETA_MARKETING_VERSION for scheduled betas. - # Scheduled builds use: + # Reuse the checked-in CMUX_IOS_BETA_MARKETING_VERSION for automatic betas. + # Main-push builds use: # - "cmux INTERNAL" display name # - dev.cmux.app.internal bundle ID (separate app, can coexist with external) # - assigned to internal TestFlight group for dogfooding within the team @@ -606,7 +492,7 @@ jobs: assign-internal-group: name: Assign build to internal TestFlight group needs: [decide, upload] - if: (needs.decide.outputs.should_build == 'true' || needs.decide.outputs.should_assign_only == 'true') && github.ref == 'refs/heads/main' && (needs.upload.result == 'success' || needs.decide.outputs.should_assign_only == 'true') + if: github.ref == 'refs/heads/main' && needs.upload.result == 'success' runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 40 env: @@ -619,30 +505,14 @@ jobs: # or --group-name"), which failed every internal assignment while both # repo variables were wired in. CMUX_TESTFLIGHT_INTERNAL_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }} - GH_TOKEN: ${{ github.token }} - SHOULD_BUILD: ${{ needs.decide.outputs.should_build }} - SHOULD_ASSIGN_ONLY: ${{ needs.decide.outputs.should_assign_only }} - LAST_UPLOADED_RUN_ID: ${{ needs.decide.outputs.last_uploaded_run_id }} BUILD_NUMBER: ${{ needs.upload.outputs.final_build_number }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Restore previous uploaded build metadata - if: env.SHOULD_ASSIGN_ONLY == 'true' - run: | - set -euo pipefail - gh run download "$LAST_UPLOADED_RUN_ID" --repo manaflow-ai/cmux \ - -n ios-testflight-build-metadata \ - -D "$RUNNER_TEMP/ios-testflight-build" - BUILD_NUMBER="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["build_number"])' "$RUNNER_TEMP/ios-testflight-build/ios-testflight-build.json")" - echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" - - name: Assign uploaded build to the internal beta group - id: assign run: | set -euo pipefail - export CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE="$RUNNER_TEMP/ios-testflight-assign-state.txt" if [ -z "${BUILD_NUMBER:-}" ] || [ "$BUILD_NUMBER" = "unknown" ]; then echo "missing uploaded build number for internal TestFlight assignment" >&2 exit 1 @@ -650,19 +520,3 @@ jobs: python3 ./ios/scripts/asc_assign_internal_testflight_group.py \ --bundle-id dev.cmux.app.internal \ --build-number "$BUILD_NUMBER" - ASSIGNMENT_STATE="unknown" - if [ -f "$CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE" ]; then - ASSIGNMENT_STATE="$(cat "$CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE")" - fi - echo "assignment_state=$ASSIGNMENT_STATE" >> "$GITHUB_OUTPUT" - - - name: Upload assignment-state artifact - if: success() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - # The decide job's assign-only retry logic looks for this exact - # artifact name (internal groups have no beta-review "pending" state, - # so a successful run is always complete). - name: ios-testflight-assignment-state-complete - path: ${{ runner.temp }}/ios-testflight-assign-state.txt - retention-days: 30 diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 52c7c8ee81a2..ae62044f16d9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -18,6 +18,7 @@ struct MobileSettingsView: View { @Environment(AuthCoordinator.self) private var authManager @Environment(MobilePushCoordinator.self) private var pushCoordinator @Environment(MobileDisplaySettings.self) private var displaySettings + @Environment(ToastCenter.self) private var toasts @Environment(\.irohSettingsController) private var irohSettingsController let connectedHostName: String let rescanQR: (() -> Void)? @@ -42,7 +43,6 @@ struct MobileSettingsView: View { @State private var showingChatDemo = false @State private var showingTerminalDemo = false @State private var showingToastGallery = false - @Environment(ToastCenter.self) private var toasts /// Seconds between tapping "Run Toast Demo" and the first toast, so you /// can navigate to any screen (terminal, chat) and watch it play there. @AppStorage("cmux.debug.toastDemoDelaySeconds") private var toastDemoDelaySeconds = 3 diff --git a/ios/scripts/upload-testflight.sh b/ios/scripts/upload-testflight.sh index e63022a4d736..054fbd5ef390 100755 --- a/ios/scripts/upload-testflight.sh +++ b/ios/scripts/upload-testflight.sh @@ -338,8 +338,8 @@ Options: CMUX_TESTFLIGHT_SKIP_NOTES=1. --notes-from-range Auto-generate the "What to Test" notes from the iOS-affecting commits in ..HEAD instead of the - ios/CHANGELOG.md top entry (used by the every-2h beta - lane so each build's notes reflect what changed since + ios/CHANGELOG.md top entry (used by the every-main-push + beta lane so each build's notes reflect what changed since the previous beta for the selected audience). Skips the changelog preflight and version-match guard. --auto-version Stamp the beta build's MARKETING_VERSION at archive time @@ -432,9 +432,9 @@ if [[ "${CMUX_TESTFLIGHT_EXTERNAL:-}" == "1" ]]; then EXTERNAL_TESTING=1 fi # Whether this invocation should assign an uploaded external build to the -# external beta group itself. The scheduled GitHub Actions lane disables this and +# external beta group itself. The automatic GitHub Actions lane disables this and # runs assignment in a separate post-upload job so a distribution failure cannot -# cause duplicate uploads of the same SHA on the next schedule. +# cause duplicate uploads after the IPA already shipped. ASSIGN_EXTERNAL_GROUP=1 if [[ "${CMUX_TESTFLIGHT_ASSIGN_EXTERNAL_GROUP:-1}" == "0" ]]; then ASSIGN_EXTERNAL_GROUP=0 @@ -448,7 +448,7 @@ if [[ "${CMUX_TESTFLIGHT_SKIP_NOTES:-}" == "1" ]]; then fi # --notes-from-range : auto-generate the "What to Test" notes from the # iOS-affecting commits in ..HEAD (via generate-testflight-notes.sh) instead -# of the hand-maintained ios/CHANGELOG.md top entry. Used by the every-2h beta +# of the hand-maintained ios/CHANGELOG.md top entry. Used by the every-main-push beta # lane so each build's notes reflect what actually changed since the previous # beta for whichever audience is being shipped. When set, the changelog # preflight + version-match guard are skipped (the notes no longer come from the diff --git a/tests/test_ios_testflight_every_main_push.py b/tests/test_ios_testflight_every_main_push.py new file mode 100644 index 000000000000..c338c20b1bc0 --- /dev/null +++ b/tests/test_ios_testflight_every_main_push.py @@ -0,0 +1,56 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "ios-testflight.yml" + + +def workflow_text() -> str: + return WORKFLOW.read_text(encoding="utf-8") + + +def trigger_block(text: str) -> str: + return text[text.index("on:\n") : text.index("\nconcurrency:\n")] + + +def test_every_main_push_triggers_without_path_or_schedule_gates() -> None: + text = workflow_text() + triggers = trigger_block(text) + + assert " push:\n branches:\n - main\n" in triggers + assert " workflow_dispatch:\n" in triggers + assert "schedule:" not in triggers + assert "paths:" not in triggers + assert "'schedule'" not in text + assert "context.eventName === 'push' || context.eventName === 'workflow_dispatch'" in text + + +def test_main_push_runs_are_preserved_and_uploaded_in_order() -> None: + text = workflow_text() + + assert ( + "group: ios-testflight-${{ github.event_name == 'push' && github.sha || github.run_id }}" + in text + ) + assert "group: ios-testflight-${{ github.ref_name }}" not in text + assert "cancel-in-progress: false" in text + assert "Number(run.id) < currentRunId" in text + assert "uploadJob.status !== 'completed'" in text + assert "could not inspect earlier TestFlight runs; retrying" in text + assert "ios-testflight-assignment-state-complete" not in text + assert "CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE" not in text + + +def test_automatic_lane_stays_on_cmux_internal_identity() -> None: + text = workflow_text() + + assert "IOS_BETA_BUNDLE_ID: dev.cmux.app.internal" in text + assert "IOS_BETA_DISPLAY_NAME: cmux INTERNAL" in text + assert "--bundle-id dev.cmux.app.internal" in text + + +if __name__ == "__main__": + test_every_main_push_triggers_without_path_or_schedule_gates() + test_main_push_runs_are_preserved_and_uploaded_in_order() + test_automatic_lane_stays_on_cmux_internal_identity() + print("all iOS TestFlight main-push workflow tests passed")