From 431f1e2957402a2081b680a4a04ef69d5b115c79 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:53:01 -0700 Subject: [PATCH 01/23] test(ci): align TestFlight guard with scheduled uploads --- tests/test_ios_testflight_main_push_filter.py | 99 +++++++++++++------ 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 7bcf5be9ffaf..adddce66908f 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -19,22 +19,26 @@ "scripts/validate-xcframework-archive.py", ".github/workflows/ios-testflight.yml", ) -DEMO_VARIANT_GUARD = ( - "github.event_name == 'workflow_dispatch' " - "&& github.event.inputs.variant == 'demo'" +IOS_SCHEDULES = ( + "7 * * * *", + "37 5,17 * * *", ) +RESOLVED_DEMO_VARIANT_GUARD = "needs.decide.outputs.variant == 'demo'" MARKETING_OVERRIDE_GUARD = "github.event.inputs.marketing_version_override != ''" def variant_choice(demo: str, internal: str) -> str: - return f"${{{{ {DEMO_VARIANT_GUARD} && '{demo}' || '{internal}' }}}}" + return ( + f"${{{{ {RESOLVED_DEMO_VARIANT_GUARD} " + f"&& '{demo}' || '{internal}' }}}}" + ) def summary_choice(external: str, demo: str, internal: str) -> str: return ( "${{ " f"{MARKETING_OVERRIDE_GUARD} && '{external}' " - f"|| {DEMO_VARIANT_GUARD} && '{demo}' " + f"|| {RESOLVED_DEMO_VARIANT_GUARD} && '{demo}' " f"|| '{internal}' }}" ) @@ -82,34 +86,60 @@ def mapping_keys(text: str, indent: int) -> tuple[str, ...]: return tuple(keys) -def sequence_values(text: str, key: str, indent: int) -> tuple[str, ...]: - marker = f"{' ' * indent}{key}:\n" - assert marker in text, f"missing {key} sequence" - lines = text[text.index(marker) + len(marker) :].splitlines() - item_prefix = f"{' ' * (indent + 2)}- " +def sequence_mapping_values( + text: str, + key: str, + indent: int, +) -> tuple[str, ...]: + item_prefix = f"{' ' * indent}- {key}: " values = [] - for line in lines: + for line in text.splitlines(): if not line.strip() or line.lstrip().startswith("#"): continue - line_indent = len(line) - len(line.lstrip()) - if line_indent <= indent: - break - assert line.startswith(item_prefix), f"invalid {key} item: {line}" + if not line.startswith(item_prefix): + continue parsed = shlex.split(line.removeprefix(item_prefix), comments=True) assert len(parsed) == 1, f"invalid {key} value: {line}" values.append(parsed[0]) return tuple(values) -def test_main_push_triggers_only_for_ios_affecting_paths() -> None: +def javascript_string_array(text: str, name: str) -> tuple[str, ...]: + marker = f"const {name} = [" + assert marker in text, f"missing {name} array" + block = text.split(marker, 1)[1].split("];", 1)[0] + values = [] + for line in block.splitlines(): + value = line.strip() + if not value or value.startswith("//"): + continue + assert value.endswith(","), f"invalid {name} item: {line}" + parsed = shlex.split(value.removesuffix(","), comments=True) + assert len(parsed) == 1, f"invalid {name} value: {line}" + values.append(parsed[0]) + return tuple(values) + + +def test_scheduled_uploads_filter_for_ios_affecting_main_changes() -> None: text = workflow_text() triggers = trigger_block(text) - push = mapping_block(triggers, "push", indent=2) + schedule = mapping_block(triggers, "schedule", indent=2) + expected_workflow_paths = tuple( + path.removesuffix("**") for path in IOS_PATHS + ) - assert mapping_keys(triggers, indent=2) == ("push", "workflow_dispatch") - assert sequence_values(push, "branches", indent=4) == ("main",) - assert sequence_values(push, "paths", indent=4) == IOS_PATHS - assert "context.eventName === 'push' || context.eventName === 'workflow_dispatch'" in text + assert mapping_keys(triggers, indent=2) == ( + "schedule", + "workflow_dispatch", + ) + assert sequence_mapping_values(schedule, "cron", indent=4) == IOS_SCHEDULES + assert ( + javascript_string_array(text, "iosRelevantPaths") + == expected_workflow_paths + ) + assert "let shouldBuild = true;" in text + assert "if (context.eventName === 'schedule')" in text + assert "github.rest.repos.compareCommits" in text def test_mapping_keys_normalizes_quoted_yaml_keys() -> None: @@ -139,16 +169,15 @@ def test_testflight_notes_use_the_same_ios_path_contract() -> None: assert notes_paths == expected_notes_paths -def test_main_push_runs_are_preserved_and_uploaded_in_order() -> None: +def test_scheduled_and_manual_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.run_id }}" in text + assert "github.event_name == 'push' && github.sha" not 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 "['push', 'schedule', 'workflow_dispatch'].includes(run.event)" 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 @@ -157,6 +186,7 @@ def test_main_push_runs_are_preserved_and_uploaded_in_order() -> None: def test_automatic_lane_stays_on_cmux_internal_identity() -> None: text = workflow_text() + decide = mapping_block(text, "decide", indent=2) upload = mapping_block(text, "upload", indent=2) assignment = mapping_block(text, "assign-internal-group", indent=2) @@ -164,11 +194,18 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: display_name_choice = variant_choice("cmux DEMO", "cmux INTERNAL") group_choice = ( "${{ " - f"{DEMO_VARIANT_GUARD} " + f"{RESOLVED_DEMO_VARIANT_GUARD} " "&& 'dd5c5cde-05a6-44e5-bd71-c2ec08a3ebfe' " "|| vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }}" ) + assert "context.payload?.inputs?.variant === 'demo'" in decide + assert ( + "context.eventName === 'schedule' " + "&& context.payload?.schedule === demoCron" + in decide + ) + assert "core.setOutput('variant', variant)" in decide assert upload.count(f"IOS_BETA_BUNDLE_ID: {bundle_choice}") == 2 assert upload.count(f"IOS_BETA_DISPLAY_NAME: {display_name_choice}") == 2 assert ( @@ -196,7 +233,7 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: f"{override_choice('Beta App Review may be required', 'no beta review needed')}" in upload ) - assert f"if: {DEMO_VARIANT_GUARD}" in upload + assert f"if: {RESOLVED_DEMO_VARIANT_GUARD}" in upload assert ( 'echo "- lane: \\`beta\\` ' '(bundle id \\`${UPLOAD_BUNDLE_ID}\\`, ${UPLOAD_AUDIENCE})"' @@ -219,9 +256,9 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: if __name__ == "__main__": - test_main_push_triggers_only_for_ios_affecting_paths() + test_scheduled_uploads_filter_for_ios_affecting_main_changes() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() - test_main_push_runs_are_preserved_and_uploaded_in_order() + test_scheduled_and_manual_runs_are_preserved_and_uploaded_in_order() test_automatic_lane_stays_on_cmux_internal_identity() - print("all iOS TestFlight main-push filter tests passed") + print("all iOS TestFlight scheduling tests passed") From d1473d64a0d49020227980f535d72860da4cebd9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:02:36 -0700 Subject: [PATCH 02/23] test(ci): execute TestFlight scheduling decision --- tests/test_ios_testflight_main_push_filter.py | 222 +++++++++++++++++- 1 file changed, 211 insertions(+), 11 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index adddce66908f..7d8647a985d0 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -1,5 +1,8 @@ +import json import shlex +import subprocess from pathlib import Path +from typing import Optional ROOT = Path(__file__).resolve().parents[1] @@ -71,6 +74,23 @@ def mapping_block(text: str, key: str, indent: int) -> str: return "\n".join(block) +def literal_block(text: str, key: str, indent: int) -> str: + marker = f"{' ' * indent}{key}: |\n" + assert marker in text, f"missing {key} literal block" + lines = text[text.index(marker) + len(marker) :].splitlines() + content_prefix = " " * (indent + 2) + block = [] + for line in lines: + if line.strip() and len(line) - len(line.lstrip()) <= indent: + break + if not line: + block.append("") + continue + assert line.startswith(content_prefix), f"invalid {key} line: {line}" + block.append(line.removeprefix(content_prefix)) + return "\n".join(block) + + def mapping_keys(text: str, indent: int) -> tuple[str, ...]: keys = [] for line in text.splitlines(): @@ -120,6 +140,123 @@ def javascript_string_array(text: str, name: str) -> tuple[str, ...]: return tuple(values) +def run_decision_scenario( + *, + event_name: str, + schedule: Optional[str] = None, + input_variant: str = "internal", + prior_sha: Optional[str] = None, + prior_artifact: str = "ios-testflight-build-metadata", + head_sha: str = "head-sha", + changed_files: tuple[str, ...] = (), +) -> dict[str, object]: + decision_job = mapping_block(workflow_text(), "decide", indent=2) + decision_script = literal_block(decision_job, "script", indent=10) + scenario = { + "eventName": event_name, + "schedule": schedule, + "inputVariant": input_variant, + "priorSha": prior_sha, + "priorArtifact": prior_artifact, + "headSha": head_sha, + "changedFiles": changed_files, + } + harness = f""" +const scenario = {json.dumps(scenario)}; +const outputs = {{}}; +const compareCalls = []; +const warnings = []; +const priorRuns = scenario.priorSha + ? [{{ + id: 50, + status: 'completed', + event: 'schedule', + head_sha: scenario.priorSha, + }}] + : []; +const context = {{ + repo: {{ owner: 'manaflow-ai', repo: 'cmux' }}, + eventName: scenario.eventName, + payload: {{ + schedule: scenario.schedule, + inputs: {{ variant: scenario.inputVariant }}, + }}, + ref: 'refs/heads/main', + runId: 100, + sha: scenario.headSha, +}}; +const github = {{ + rest: {{ + actions: {{ + listWorkflowRuns: async () => ({{ + data: {{ workflow_runs: priorRuns }}, + }}), + listJobsForWorkflowRun: async () => ({{ + data: {{ + jobs: [{{ + name: 'Upload to TestFlight', + status: 'completed', + conclusion: 'success', + }}], + }}, + }}), + listWorkflowRunArtifacts: async () => ({{ + data: {{ + artifacts: [{{ name: scenario.priorArtifact }}], + }}, + }}), + }}, + repos: {{ + compareCommits: async (request) => {{ + compareCalls.push(request); + return {{ + data: {{ + files: scenario.changedFiles.map((filename) => ({{ filename }})), + }}, + }}; + }}, + }}, + }}, +}}; +const core = {{ + setOutput: (name, value) => {{ + outputs[name] = value; + }}, + setFailed: (message) => {{ + throw new Error(message); + }}, + warning: (message) => {{ + warnings.push(message); + }}, + info: () => {{}}, + summary: {{ + addHeading() {{ + return this; + }}, + addTable() {{ + return this; + }}, + write() {{ + return this; + }}, + }}, +}}; +async function runDecision() {{ +{decision_script} +}} +await runDecision(); +process.stdout.write(JSON.stringify({{ outputs, compareCalls, warnings }})); +""" + result = subprocess.run( + ["bun", "-e", harness], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + def test_scheduled_uploads_filter_for_ios_affecting_main_changes() -> None: text = workflow_text() triggers = trigger_block(text) @@ -137,9 +274,77 @@ def test_scheduled_uploads_filter_for_ios_affecting_main_changes() -> None: javascript_string_array(text, "iosRelevantPaths") == expected_workflow_paths ) - assert "let shouldBuild = true;" in text - assert "if (context.eventName === 'schedule')" in text - assert "github.rest.repos.compareCommits" in text + + +def test_schedule_decision_executes_ios_path_filter() -> None: + ios_change = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=("Sources/Mobile/MobileHostService.swift",), + ) + non_ios_change = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=("docs/cli-contract.md",), + ) + + assert ios_change["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + assert non_ios_change["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + expected_compare = [ + { + "owner": "manaflow-ai", + "repo": "cmux", + "base": "base-sha", + "head": "head-sha", + } + ] + assert ios_change["compareCalls"] == expected_compare + assert non_ios_change["compareCalls"] == expected_compare + + +def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[1], + prior_sha="demo-base-sha", + prior_artifact="ios-testflight-build-metadata-demo", + changed_files=("docs/cli-contract.md",), + ) + + assert result["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "demo-base-sha", + "variant": "demo", + } + + +def test_manual_demo_dispatch_builds_even_when_head_already_uploaded() -> None: + result = run_decision_scenario( + event_name="workflow_dispatch", + input_variant="demo", + prior_sha="head-sha", + prior_artifact="ios-testflight-build-metadata-demo", + head_sha="head-sha", + ) + + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "head-sha", + "variant": "demo", + } + assert result["compareCalls"] == [] def test_mapping_keys_normalizes_quoted_yaml_keys() -> None: @@ -186,7 +391,6 @@ def test_scheduled_and_manual_runs_are_preserved_and_uploaded_in_order() -> None def test_automatic_lane_stays_on_cmux_internal_identity() -> None: text = workflow_text() - decide = mapping_block(text, "decide", indent=2) upload = mapping_block(text, "upload", indent=2) assignment = mapping_block(text, "assign-internal-group", indent=2) @@ -199,13 +403,6 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: "|| vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }}" ) - assert "context.payload?.inputs?.variant === 'demo'" in decide - assert ( - "context.eventName === 'schedule' " - "&& context.payload?.schedule === demoCron" - in decide - ) - assert "core.setOutput('variant', variant)" in decide assert upload.count(f"IOS_BETA_BUNDLE_ID: {bundle_choice}") == 2 assert upload.count(f"IOS_BETA_DISPLAY_NAME: {display_name_choice}") == 2 assert ( @@ -257,6 +454,9 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: if __name__ == "__main__": test_scheduled_uploads_filter_for_ios_affecting_main_changes() + test_schedule_decision_executes_ios_path_filter() + test_schedule_decision_routes_demo_cron_to_demo_history() + test_manual_demo_dispatch_builds_even_when_head_already_uploaded() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() test_scheduled_and_manual_runs_are_preserved_and_uploaded_in_order() From 48514b962735ba901dbfa082b6567f65cd88ef9c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:08:12 -0700 Subject: [PATCH 03/23] test(ci): cover whitespace-only workflow lines --- tests/test_ios_testflight_main_push_filter.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 7d8647a985d0..42c20078c28f 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -106,6 +106,12 @@ def mapping_keys(text: str, indent: int) -> tuple[str, ...]: return tuple(keys) +def test_literal_block_accepts_whitespace_only_lines() -> None: + text = " script: |\n first\n \n second\nnext:\n" + + assert literal_block(text, "script", indent=2) == "first\n\nsecond" + + def sequence_mapping_values( text: str, key: str, @@ -453,6 +459,7 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: if __name__ == "__main__": + test_literal_block_accepts_whitespace_only_lines() test_scheduled_uploads_filter_for_ios_affecting_main_changes() test_schedule_decision_executes_ios_path_filter() test_schedule_decision_routes_demo_cron_to_demo_history() From 607eefaf8889b9ace4e0915eb4b14051312956b6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:09:03 -0700 Subject: [PATCH 04/23] test(ci): execute TestFlight ordering guard --- tests/test_ios_testflight_main_push_filter.py | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 42c20078c28f..6afc705a0227 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -1,5 +1,6 @@ import json import shlex +import shutil import subprocess from pathlib import Path from typing import Optional @@ -8,6 +9,7 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "ios-testflight.yml" NOTES_GENERATOR = ROOT / "ios" / "scripts" / "generate-testflight-notes.sh" +BUN = shutil.which("bun") IOS_PATHS = ( "ios/**", "Packages/iOS/**", @@ -83,7 +85,7 @@ def literal_block(text: str, key: str, indent: int) -> str: for line in lines: if line.strip() and len(line) - len(line.lstrip()) <= indent: break - if not line: + if not line.strip(): block.append("") continue assert line.startswith(content_prefix), f"invalid {key} line: {line}" @@ -155,6 +157,7 @@ def run_decision_scenario( prior_artifact: str = "ios-testflight-build-metadata", head_sha: str = "head-sha", changed_files: tuple[str, ...] = (), + blocking_prior_run: bool = False, ) -> dict[str, object]: decision_job = mapping_block(workflow_text(), "decide", indent=2) decision_script = literal_block(decision_job, "script", indent=10) @@ -166,20 +169,29 @@ def run_decision_scenario( "priorArtifact": prior_artifact, "headSha": head_sha, "changedFiles": changed_files, + "blockingPriorRun": blocking_prior_run, } harness = f""" const scenario = {json.dumps(scenario)}; const outputs = {{}}; const compareCalls = []; const warnings = []; -const priorRuns = scenario.priorSha +const waitCalls = []; +let workflowRunCalls = 0; +let blockingReleased = !scenario.blockingPriorRun; +const priorRuns = () => scenario.priorSha ? [{{ id: 50, - status: 'completed', + status: blockingReleased ? 'completed' : 'in_progress', event: 'schedule', head_sha: scenario.priorSha, }}] : []; +const setTimeout = (resolve, milliseconds) => {{ + waitCalls.push(milliseconds); + blockingReleased = true; + resolve(); +}}; const context = {{ repo: {{ owner: 'manaflow-ai', repo: 'cmux' }}, eventName: scenario.eventName, @@ -194,15 +206,18 @@ def run_decision_scenario( const github = {{ rest: {{ actions: {{ - listWorkflowRuns: async () => ({{ - data: {{ workflow_runs: priorRuns }}, - }}), + listWorkflowRuns: async () => {{ + workflowRunCalls += 1; + return {{ + data: {{ workflow_runs: priorRuns() }}, + }}; + }}, listJobsForWorkflowRun: async () => ({{ data: {{ jobs: [{{ name: 'Upload to TestFlight', - status: 'completed', - conclusion: 'success', + status: blockingReleased ? 'completed' : 'in_progress', + conclusion: blockingReleased ? 'success' : null, }}], }}, }}), @@ -251,10 +266,17 @@ def run_decision_scenario( {decision_script} }} await runDecision(); -process.stdout.write(JSON.stringify({{ outputs, compareCalls, warnings }})); +process.stdout.write(JSON.stringify({{ + outputs, + compareCalls, + warnings, + waitCalls, + workflowRunCalls, +}})); """ + assert BUN is not None, "bun is required to execute the decide job harness" result = subprocess.run( - ["bun", "-e", harness], + [BUN, "-e", harness], check=False, capture_output=True, text=True, @@ -353,6 +375,25 @@ def test_manual_demo_dispatch_builds_even_when_head_already_uploaded() -> None: assert result["compareCalls"] == [] +def test_scheduled_run_waits_for_an_earlier_upload() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=("ios/cmux/App.swift",), + blocking_prior_run=True, + ) + + assert result["waitCalls"] == [60_000] + assert result["workflowRunCalls"] == 3 + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + + def test_mapping_keys_normalizes_quoted_yaml_keys() -> None: triggers = " push:\n 'schedule':\n \"workflow_dispatch\":\n" @@ -380,17 +421,13 @@ def test_testflight_notes_use_the_same_ios_path_contract() -> None: assert notes_paths == expected_notes_paths -def test_scheduled_and_manual_runs_are_preserved_and_uploaded_in_order() -> None: +def test_scheduled_and_manual_runs_use_independent_concurrency_groups() -> None: text = workflow_text() assert "group: ios-testflight-${{ github.run_id }}" in text assert "github.event_name == 'push' && github.sha" not 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 "['push', 'schedule', 'workflow_dispatch'].includes(run.event)" 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 @@ -464,8 +501,9 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_schedule_decision_executes_ios_path_filter() test_schedule_decision_routes_demo_cron_to_demo_history() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() + test_scheduled_run_waits_for_an_earlier_upload() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() - test_scheduled_and_manual_runs_are_preserved_and_uploaded_in_order() + test_scheduled_and_manual_runs_use_independent_concurrency_groups() test_automatic_lane_stays_on_cmux_internal_identity() print("all iOS TestFlight scheduling tests passed") From 566e20d88b2493da8f55e272a253041f489b0859 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:14:19 -0700 Subject: [PATCH 05/23] test(ci): verify TestFlight artifact handoff --- tests/test_ios_testflight_main_push_filter.py | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 6afc705a0227..c0fc2a9a38f0 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -148,6 +148,17 @@ def javascript_string_array(text: str, name: str) -> tuple[str, ...]: return tuple(values) +def github_expression_containing(text: str, needle: str) -> str: + matches = [ + line.strip() + for line in text.splitlines() + if needle in line and "${{" in line + ] + assert len(matches) == 1, f"expected one expression containing {needle}" + expression = matches[0] + return expression.split("${{", 1)[1].rsplit("}}", 1)[0].strip() + + def run_decision_scenario( *, event_name: str, @@ -161,6 +172,10 @@ def run_decision_scenario( ) -> dict[str, object]: decision_job = mapping_block(workflow_text(), "decide", indent=2) decision_script = literal_block(decision_job, "script", indent=10) + artifact_expression = github_expression_containing( + workflow_text(), + "ios-testflight-build-metadata-override", + ) scenario = { "eventName": event_name, "schedule": schedule, @@ -204,6 +219,12 @@ def run_decision_scenario( sha: scenario.headSha, }}; const github = {{ + event: {{ + inputs: {{ + marketing_version_override: '', + variant: scenario.inputVariant, + }}, + }}, rest: {{ actions: {{ listWorkflowRuns: async () => {{ @@ -266,12 +287,15 @@ def run_decision_scenario( {decision_script} }} await runDecision(); +const needs = {{ decide: {{ outputs }} }}; +const producedArtifactName = {artifact_expression}; process.stdout.write(JSON.stringify({{ outputs, compareCalls, warnings, waitCalls, workflowRunCalls, + producedArtifactName, }})); """ assert BUN is not None, "bun is required to execute the decide job harness" @@ -343,11 +367,26 @@ def test_schedule_decision_executes_ios_path_filter() -> None: def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: + first_run = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[1], + input_variant="", + ) + produced_artifact = first_run["producedArtifactName"] + assert isinstance(produced_artifact, str) + assert first_run["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "", + "variant": "demo", + } + assert produced_artifact == "ios-testflight-build-metadata-demo" + result = run_decision_scenario( event_name="schedule", schedule=IOS_SCHEDULES[1], + input_variant="", prior_sha="demo-base-sha", - prior_artifact="ios-testflight-build-metadata-demo", + prior_artifact=produced_artifact, changed_files=("docs/cli-contract.md",), ) From 9902d678cd1650a772b3c48ff2b88495340adb78 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:19:18 -0700 Subject: [PATCH 06/23] test(ci): cover every TestFlight scheduling path --- tests/test_ios_testflight_main_push_filter.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index c0fc2a9a38f0..8d0c744c3acf 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -329,13 +329,20 @@ def test_scheduled_uploads_filter_for_ios_affecting_main_changes() -> None: def test_schedule_decision_executes_ios_path_filter() -> None: - ios_change = run_decision_scenario( - event_name="schedule", - schedule=IOS_SCHEDULES[0], - prior_sha="base-sha", - head_sha="head-sha", - changed_files=("Sources/Mobile/MobileHostService.swift",), - ) + ios_changes = [ + run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=( + path.removesuffix("**") + "changed" + if path.endswith("**") + else path, + ), + ) + for path in IOS_PATHS + ] non_ios_change = run_decision_scenario( event_name="schedule", schedule=IOS_SCHEDULES[0], @@ -344,11 +351,12 @@ def test_schedule_decision_executes_ios_path_filter() -> None: changed_files=("docs/cli-contract.md",), ) - assert ios_change["outputs"] == { - "should_build": "true", - "last_uploaded_sha": "base-sha", - "variant": "internal", - } + for ios_change in ios_changes: + assert ios_change["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } assert non_ios_change["outputs"] == { "should_build": "false", "last_uploaded_sha": "base-sha", @@ -362,7 +370,10 @@ def test_schedule_decision_executes_ios_path_filter() -> None: "head": "head-sha", } ] - assert ios_change["compareCalls"] == expected_compare + assert all( + ios_change["compareCalls"] == expected_compare + for ios_change in ios_changes + ) assert non_ios_change["compareCalls"] == expected_compare @@ -426,6 +437,16 @@ def test_scheduled_run_waits_for_an_earlier_upload() -> None: assert result["waitCalls"] == [60_000] assert result["workflowRunCalls"] == 3 + assert result["priorRunStatuses"] == [ + "in_progress", + "in_progress", + "in_progress", + ] + assert result["uploadJobStatuses"] == [ + "in_progress", + "completed", + "completed", + ] assert result["outputs"] == { "should_build": "true", "last_uploaded_sha": "base-sha", From 7797243f386edabd37428bdc005bd00c20eb9ef3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:19:37 -0700 Subject: [PATCH 07/23] test(ci): model post-upload workflow activity --- tests/test_ios_testflight_main_push_filter.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 8d0c744c3acf..42cc046b1b16 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -192,19 +192,21 @@ def run_decision_scenario( const compareCalls = []; const warnings = []; const waitCalls = []; +const priorRunStatuses = []; +const uploadJobStatuses = []; let workflowRunCalls = 0; -let blockingReleased = !scenario.blockingPriorRun; +let uploadReleased = !scenario.blockingPriorRun; const priorRuns = () => scenario.priorSha ? [{{ id: 50, - status: blockingReleased ? 'completed' : 'in_progress', + status: scenario.blockingPriorRun ? 'in_progress' : 'completed', event: 'schedule', head_sha: scenario.priorSha, }}] : []; const setTimeout = (resolve, milliseconds) => {{ waitCalls.push(milliseconds); - blockingReleased = true; + uploadReleased = true; resolve(); }}; const context = {{ @@ -229,19 +231,25 @@ def run_decision_scenario( actions: {{ listWorkflowRuns: async () => {{ workflowRunCalls += 1; + const workflowRuns = priorRuns(); + priorRunStatuses.push(...workflowRuns.map((run) => run.status)); return {{ - data: {{ workflow_runs: priorRuns() }}, + data: {{ workflow_runs: workflowRuns }}, + }}; + }}, + listJobsForWorkflowRun: async () => {{ + const status = uploadReleased ? 'completed' : 'in_progress'; + uploadJobStatuses.push(status); + return {{ + data: {{ + jobs: [{{ + name: 'Upload to TestFlight', + status, + conclusion: uploadReleased ? 'success' : null, + }}], + }}, }}; }}, - listJobsForWorkflowRun: async () => ({{ - data: {{ - jobs: [{{ - name: 'Upload to TestFlight', - status: blockingReleased ? 'completed' : 'in_progress', - conclusion: blockingReleased ? 'success' : null, - }}], - }}, - }}), listWorkflowRunArtifacts: async () => ({{ data: {{ artifacts: [{{ name: scenario.priorArtifact }}], @@ -295,6 +303,8 @@ def run_decision_scenario( warnings, waitCalls, workflowRunCalls, + priorRunStatuses, + uploadJobStatuses, producedArtifactName, }})); """ From 5f96027cf8d5cfe829f177c7e849ba495bff22ed Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:23:19 -0700 Subject: [PATCH 08/23] test(ci): cover manual TestFlight upload ordering --- tests/test_ios_testflight_main_push_filter.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 42cc046b1b16..cdc5b53dd357 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -464,6 +464,36 @@ def test_scheduled_run_waits_for_an_earlier_upload() -> None: } +def test_ordering_includes_manual_current_and_prior_runs() -> None: + scenarios = ( + ("workflow_dispatch", "schedule"), + ("schedule", "workflow_dispatch"), + ) + + for event_name, prior_event in scenarios: + result = run_decision_scenario( + event_name=event_name, + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + prior_event=prior_event, + head_sha="head-sha", + changed_files=("ios/cmux/App.swift",), + blocking_prior_run=True, + ) + + assert result["waitCalls"] == [60_000] + assert result["uploadJobStatuses"] == [ + "in_progress", + "completed", + "completed", + ] + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + + def test_mapping_keys_normalizes_quoted_yaml_keys() -> None: triggers = " push:\n 'schedule':\n \"workflow_dispatch\":\n" @@ -572,6 +602,7 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_schedule_decision_routes_demo_cron_to_demo_history() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() test_scheduled_run_waits_for_an_earlier_upload() + test_ordering_includes_manual_current_and_prior_runs() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() test_scheduled_and_manual_runs_use_independent_concurrency_groups() From a54e0c8c91a41c541f562e7d7b6d01d881f47155 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:23:32 -0700 Subject: [PATCH 09/23] test(ci): parameterize prior TestFlight event --- tests/test_ios_testflight_main_push_filter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index cdc5b53dd357..a82abc051c8e 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -165,6 +165,7 @@ def run_decision_scenario( schedule: Optional[str] = None, input_variant: str = "internal", prior_sha: Optional[str] = None, + prior_event: str = "schedule", prior_artifact: str = "ios-testflight-build-metadata", head_sha: str = "head-sha", changed_files: tuple[str, ...] = (), @@ -181,6 +182,7 @@ def run_decision_scenario( "schedule": schedule, "inputVariant": input_variant, "priorSha": prior_sha, + "priorEvent": prior_event, "priorArtifact": prior_artifact, "headSha": head_sha, "changedFiles": changed_files, @@ -200,7 +202,7 @@ def run_decision_scenario( ? [{{ id: 50, status: scenario.blockingPriorRun ? 'in_progress' : 'completed', - event: 'schedule', + event: scenario.priorEvent, head_sha: scenario.priorSha, }}] : []; From 9eafca253c9fd615757652c32d7ff55b3b8c68e3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:28:25 -0700 Subject: [PATCH 10/23] test(ci): cover TestFlight scheduling fail-open cases --- tests/test_ios_testflight_main_push_filter.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index a82abc051c8e..88247fe6043d 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -389,6 +389,22 @@ def test_schedule_decision_executes_ios_path_filter() -> None: assert non_ios_change["compareCalls"] == expected_compare +def test_truncated_schedule_comparison_fails_open() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=tuple(f"docs/generated-{index}.md" for index in range(300)), + ) + + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + + def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: first_run = run_decision_scenario( event_name="schedule", @@ -420,6 +436,33 @@ def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: } +def test_demo_history_skips_newer_internal_artifact() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[1], + input_variant="", + prior_uploads=( + ("newer-internal-sha", "ios-testflight-build-metadata"), + ("demo-base-sha", "ios-testflight-build-metadata-demo"), + ), + changed_files=("docs/cli-contract.md",), + ) + + assert result["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "demo-base-sha", + "variant": "demo", + } + assert result["compareCalls"] == [ + { + "owner": "manaflow-ai", + "repo": "cmux", + "base": "demo-base-sha", + "head": "head-sha", + } + ] + + def test_manual_demo_dispatch_builds_even_when_head_already_uploaded() -> None: result = run_decision_scenario( event_name="workflow_dispatch", @@ -466,6 +509,31 @@ def test_scheduled_run_waits_for_an_earlier_upload() -> None: } +def test_scheduled_run_waits_before_upload_job_exists() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=("ios/cmux/App.swift",), + blocking_prior_run=True, + upload_job_starts_late=True, + ) + + assert result["waitCalls"] == [60_000, 60_000] + assert result["uploadJobStatuses"] == [ + None, + "in_progress", + "completed", + "completed", + ] + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + + def test_ordering_includes_manual_current_and_prior_runs() -> None: scenarios = ( ("workflow_dispatch", "schedule"), @@ -601,9 +669,12 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_literal_block_accepts_whitespace_only_lines() test_scheduled_uploads_filter_for_ios_affecting_main_changes() test_schedule_decision_executes_ios_path_filter() + test_truncated_schedule_comparison_fails_open() test_schedule_decision_routes_demo_cron_to_demo_history() + test_demo_history_skips_newer_internal_artifact() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() test_scheduled_run_waits_for_an_earlier_upload() + test_scheduled_run_waits_before_upload_job_exists() test_ordering_includes_manual_current_and_prior_runs() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() From 5737dac22f9c64288db7c8a5f494c408f4d29fcd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:29:08 -0700 Subject: [PATCH 11/23] test(ci): model TestFlight upload history phases --- tests/test_ios_testflight_main_push_filter.py | 77 ++++++++++++++----- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 88247fe6043d..178d885b84b3 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -167,9 +167,11 @@ def run_decision_scenario( prior_sha: Optional[str] = None, prior_event: str = "schedule", prior_artifact: str = "ios-testflight-build-metadata", + prior_uploads: tuple[tuple[str, str], ...] = (), head_sha: str = "head-sha", changed_files: tuple[str, ...] = (), blocking_prior_run: bool = False, + upload_job_starts_late: bool = False, ) -> dict[str, object]: decision_job = mapping_block(workflow_text(), "decide", indent=2) decision_script = literal_block(decision_job, "script", indent=10) @@ -177,16 +179,30 @@ def run_decision_scenario( workflow_text(), "ios-testflight-build-metadata-override", ) + assert not ( + prior_sha and prior_uploads + ), "use prior_sha or prior_uploads, not both" + upload_history = prior_uploads + if prior_sha: + upload_history = ((prior_sha, prior_artifact),) + prior_runs = [ + { + "id": 50 - index, + "sha": sha, + "artifact": artifact, + "event": prior_event, + } + for index, (sha, artifact) in enumerate(upload_history) + ] scenario = { "eventName": event_name, "schedule": schedule, "inputVariant": input_variant, - "priorSha": prior_sha, - "priorEvent": prior_event, - "priorArtifact": prior_artifact, + "priorRuns": prior_runs, "headSha": head_sha, "changedFiles": changed_files, "blockingPriorRun": blocking_prior_run, + "uploadJobStartsLate": upload_job_starts_late, } harness = f""" const scenario = {json.dumps(scenario)}; @@ -197,18 +213,21 @@ def run_decision_scenario( const priorRunStatuses = []; const uploadJobStatuses = []; let workflowRunCalls = 0; -let uploadReleased = !scenario.blockingPriorRun; -const priorRuns = () => scenario.priorSha - ? [{{ - id: 50, - status: scenario.blockingPriorRun ? 'in_progress' : 'completed', - event: scenario.priorEvent, - head_sha: scenario.priorSha, - }}] - : []; +let uploadPhase = scenario.blockingPriorRun + ? (scenario.uploadJobStartsLate ? 0 : 1) + : 2; +const priorRuns = () => scenario.priorRuns.map((run, index) => ({{ + id: run.id, + status: + scenario.blockingPriorRun && index === 0 + ? 'in_progress' + : 'completed', + event: run.event, + head_sha: run.sha, +}})); const setTimeout = (resolve, milliseconds) => {{ waitCalls.push(milliseconds); - uploadReleased = true; + uploadPhase = Math.min(uploadPhase + 1, 2); resolve(); }}; const context = {{ @@ -239,24 +258,40 @@ def run_decision_scenario( data: {{ workflow_runs: workflowRuns }}, }}; }}, - listJobsForWorkflowRun: async () => {{ - const status = uploadReleased ? 'completed' : 'in_progress'; + listJobsForWorkflowRun: async (request) => {{ + const priorRun = scenario.priorRuns.find( + (run) => run.id === Number(request.run_id) + ); + const isBlockingRun = + scenario.blockingPriorRun && + priorRun?.id === scenario.priorRuns[0]?.id; + const phase = isBlockingRun ? uploadPhase : 2; + if (phase === 0) {{ + uploadJobStatuses.push(null); + return {{ data: {{ jobs: [] }} }}; + }} + const status = phase === 2 ? 'completed' : 'in_progress'; uploadJobStatuses.push(status); return {{ data: {{ jobs: [{{ name: 'Upload to TestFlight', status, - conclusion: uploadReleased ? 'success' : null, + conclusion: phase === 2 ? 'success' : null, }}], }}, }}; }}, - listWorkflowRunArtifacts: async () => ({{ - data: {{ - artifacts: [{{ name: scenario.priorArtifact }}], - }}, - }}), + listWorkflowRunArtifacts: async (request) => {{ + const priorRun = scenario.priorRuns.find( + (run) => run.id === Number(request.run_id) + ); + return {{ + data: {{ + artifacts: priorRun ? [{{ name: priorRun.artifact }}] : [], + }}, + }}; + }}, }}, repos: {{ compareCommits: async (request) => {{ From 152fb44140821529146b3377ed9fd5d6954f8272 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:34:48 -0700 Subject: [PATCH 12/23] test(ci): cover TestFlight scheduling API failures --- tests/test_ios_testflight_main_push_filter.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 178d885b84b3..15b0e29440c0 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -440,6 +440,25 @@ def test_truncated_schedule_comparison_fails_open() -> None: } +def test_schedule_comparison_failure_fails_open() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + compare_api_failure=True, + ) + + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + assert result["warnings"] == [ + "could not compare against last upload: transient compare failure" + ] + + def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: first_run = run_decision_scenario( event_name="schedule", @@ -569,6 +588,37 @@ def test_scheduled_run_waits_before_upload_job_exists() -> None: } +def test_ordering_retries_transient_api_failures() -> None: + for failed_api in ("runs", "jobs"): + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="base-sha", + head_sha="head-sha", + changed_files=("ios/cmux/App.swift",), + blocking_prior_run=True, + upload_job_starts_late=True, + ordering_api_failure=failed_api, + ) + + assert result["waitCalls"] == [60_000, 60_000] + assert result["workflowRunCalls"] == 4 + assert result["uploadJobStatuses"] == [ + "in_progress", + "completed", + "completed", + ] + assert result["warnings"] == [ + "could not inspect earlier TestFlight runs; retrying: " + f"transient {failed_api} failure" + ] + assert result["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + + def test_ordering_includes_manual_current_and_prior_runs() -> None: scenarios = ( ("workflow_dispatch", "schedule"), @@ -705,11 +755,13 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_scheduled_uploads_filter_for_ios_affecting_main_changes() test_schedule_decision_executes_ios_path_filter() test_truncated_schedule_comparison_fails_open() + test_schedule_comparison_failure_fails_open() test_schedule_decision_routes_demo_cron_to_demo_history() test_demo_history_skips_newer_internal_artifact() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() test_scheduled_run_waits_for_an_earlier_upload() test_scheduled_run_waits_before_upload_job_exists() + test_ordering_retries_transient_api_failures() test_ordering_includes_manual_current_and_prior_runs() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() From 3de1b03fe8e6e40643823d914700e28277ba947f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:35:07 -0700 Subject: [PATCH 13/23] test(ci): model transient TestFlight API failures --- tests/test_ios_testflight_main_push_filter.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 15b0e29440c0..6c59c774b7f1 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -172,6 +172,8 @@ def run_decision_scenario( changed_files: tuple[str, ...] = (), blocking_prior_run: bool = False, upload_job_starts_late: bool = False, + ordering_api_failure: Optional[str] = None, + compare_api_failure: bool = False, ) -> dict[str, object]: decision_job = mapping_block(workflow_text(), "decide", indent=2) decision_script = literal_block(decision_job, "script", indent=10) @@ -182,6 +184,11 @@ def run_decision_scenario( assert not ( prior_sha and prior_uploads ), "use prior_sha or prior_uploads, not both" + assert ordering_api_failure in ( + None, + "runs", + "jobs", + ), "ordering_api_failure must be runs or jobs" upload_history = prior_uploads if prior_sha: upload_history = ((prior_sha, prior_artifact),) @@ -203,6 +210,8 @@ def run_decision_scenario( "changedFiles": changed_files, "blockingPriorRun": blocking_prior_run, "uploadJobStartsLate": upload_job_starts_late, + "orderingApiFailure": ordering_api_failure, + "compareApiFailure": compare_api_failure, } harness = f""" const scenario = {json.dumps(scenario)}; @@ -216,6 +225,7 @@ def run_decision_scenario( let uploadPhase = scenario.blockingPriorRun ? (scenario.uploadJobStartsLate ? 0 : 1) : 2; +let orderingFailurePending = scenario.orderingApiFailure !== null; const priorRuns = () => scenario.priorRuns.map((run, index) => ({{ id: run.id, status: @@ -252,6 +262,13 @@ def run_decision_scenario( actions: {{ listWorkflowRuns: async () => {{ workflowRunCalls += 1; + if ( + orderingFailurePending && + scenario.orderingApiFailure === 'runs' + ) {{ + orderingFailurePending = false; + throw new Error('transient runs failure'); + }} const workflowRuns = priorRuns(); priorRunStatuses.push(...workflowRuns.map((run) => run.status)); return {{ @@ -259,6 +276,13 @@ def run_decision_scenario( }}; }}, listJobsForWorkflowRun: async (request) => {{ + if ( + orderingFailurePending && + scenario.orderingApiFailure === 'jobs' + ) {{ + orderingFailurePending = false; + throw new Error('transient jobs failure'); + }} const priorRun = scenario.priorRuns.find( (run) => run.id === Number(request.run_id) ); @@ -296,6 +320,9 @@ def run_decision_scenario( repos: {{ compareCommits: async (request) => {{ compareCalls.push(request); + if (scenario.compareApiFailure) {{ + throw new Error('transient compare failure'); + }} return {{ data: {{ files: scenario.changedFiles.map((filename) => ({{ filename }})), From 484af20d6b95148314b88819fe002153fecf8cfb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:40:26 -0700 Subject: [PATCH 14/23] test(ci): cover internal artifact and later-run ordering --- tests/test_ios_testflight_main_push_filter.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 6c59c774b7f1..b60e14392595 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -517,6 +517,37 @@ def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: } +def test_schedule_decision_routes_internal_cron_to_internal_history() -> None: + first_run = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + input_variant="", + ) + produced_artifact = first_run["producedArtifactName"] + assert isinstance(produced_artifact, str) + assert first_run["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "", + "variant": "internal", + } + assert produced_artifact == "ios-testflight-build-metadata" + + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + input_variant="", + prior_sha="internal-base-sha", + prior_artifact=produced_artifact, + changed_files=("docs/cli-contract.md",), + ) + + assert result["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "internal-base-sha", + "variant": "internal", + } + + def test_demo_history_skips_newer_internal_artifact() -> None: result = run_decision_scenario( event_name="schedule", @@ -646,6 +677,36 @@ def test_ordering_retries_transient_api_failures() -> None: } +def test_ordering_ignores_later_active_runs() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_uploads=( + ("newer-active-sha", "ios-testflight-build-metadata"), + ("base-sha", "ios-testflight-build-metadata"), + ), + prior_run_ids=(150, 50), + head_sha="head-sha", + changed_files=("docs/cli-contract.md",), + blocking_prior_run=True, + upload_job_starts_late=True, + ) + + assert result["waitCalls"] == [] + assert result["priorRunStatuses"] == [ + "in_progress", + "completed", + "in_progress", + "completed", + ] + assert result["uploadJobStatuses"] == [None, "completed"] + assert result["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "base-sha", + "variant": "internal", + } + + def test_ordering_includes_manual_current_and_prior_runs() -> None: scenarios = ( ("workflow_dispatch", "schedule"), @@ -784,11 +845,13 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_truncated_schedule_comparison_fails_open() test_schedule_comparison_failure_fails_open() test_schedule_decision_routes_demo_cron_to_demo_history() + test_schedule_decision_routes_internal_cron_to_internal_history() test_demo_history_skips_newer_internal_artifact() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() test_scheduled_run_waits_for_an_earlier_upload() test_scheduled_run_waits_before_upload_job_exists() test_ordering_retries_transient_api_failures() + test_ordering_ignores_later_active_runs() test_ordering_includes_manual_current_and_prior_runs() test_mapping_keys_normalizes_quoted_yaml_keys() test_testflight_notes_use_the_same_ios_path_contract() From 67aee41a974a69eaae140c542d526de83dca46b9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:40:41 -0700 Subject: [PATCH 15/23] test(ci): parameterize TestFlight run ids --- tests/test_ios_testflight_main_push_filter.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index b60e14392595..cd8cfa179153 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -168,6 +168,7 @@ def run_decision_scenario( prior_event: str = "schedule", prior_artifact: str = "ios-testflight-build-metadata", prior_uploads: tuple[tuple[str, str], ...] = (), + prior_run_ids: tuple[int, ...] = (), head_sha: str = "head-sha", changed_files: tuple[str, ...] = (), blocking_prior_run: bool = False, @@ -192,14 +193,20 @@ def run_decision_scenario( upload_history = prior_uploads if prior_sha: upload_history = ((prior_sha, prior_artifact),) + assert not prior_run_ids or len(prior_run_ids) == len( + upload_history + ), "prior_run_ids must match the upload history" + run_ids = prior_run_ids or tuple( + 50 - index for index in range(len(upload_history)) + ) prior_runs = [ { - "id": 50 - index, + "id": run_id, "sha": sha, "artifact": artifact, "event": prior_event, } - for index, (sha, artifact) in enumerate(upload_history) + for (sha, artifact), run_id in zip(upload_history, run_ids) ] scenario = { "eventName": event_name, From b83f75d80f87b84bc7206f85be88e79a51f864e1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:45:45 -0700 Subject: [PATCH 16/23] test(ci): cover scoped and unchanged TestFlight runs --- tests/test_ios_testflight_main_push_filter.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index cd8cfa179153..bf42f7e9c0f3 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -493,6 +493,24 @@ def test_schedule_comparison_failure_fails_open() -> None: ] +def test_unchanged_scheduled_head_skips_without_comparing() -> None: + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="head-sha", + head_sha="head-sha", + compare_api_failure=True, + ) + + assert result["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "head-sha", + "variant": "internal", + } + assert result["compareCalls"] == [] + assert result["warnings"] == [] + + def test_schedule_decision_routes_demo_cron_to_demo_history() -> None: first_run = run_decision_scenario( event_name="schedule", @@ -611,6 +629,30 @@ def test_scheduled_run_waits_for_an_earlier_upload() -> None: assert result["waitCalls"] == [60_000] assert result["workflowRunCalls"] == 3 + assert result["workflowRunRequests"] == [ + { + "owner": "manaflow-ai", + "repo": "cmux", + "workflow_id": "ios-testflight.yml", + "branch": "main", + "per_page": 100, + }, + { + "owner": "manaflow-ai", + "repo": "cmux", + "workflow_id": "ios-testflight.yml", + "branch": "main", + "per_page": 100, + }, + { + "owner": "manaflow-ai", + "repo": "cmux", + "workflow_id": "ios-testflight.yml", + "branch": "main", + "per_page": 100, + "page": 1, + }, + ] assert result["priorRunStatuses"] == [ "in_progress", "in_progress", @@ -851,6 +893,7 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_schedule_decision_executes_ios_path_filter() test_truncated_schedule_comparison_fails_open() test_schedule_comparison_failure_fails_open() + test_unchanged_scheduled_head_skips_without_comparing() test_schedule_decision_routes_demo_cron_to_demo_history() test_schedule_decision_routes_internal_cron_to_internal_history() test_demo_history_skips_newer_internal_artifact() From 8df9fe38cabe18b6dc74dcde120473f403ade95f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:45:57 -0700 Subject: [PATCH 17/23] test(ci): record TestFlight workflow queries --- tests/test_ios_testflight_main_push_filter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index bf42f7e9c0f3..5f013a25c28c 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -226,6 +226,7 @@ def run_decision_scenario( const compareCalls = []; const warnings = []; const waitCalls = []; +const workflowRunRequests = []; const priorRunStatuses = []; const uploadJobStatuses = []; let workflowRunCalls = 0; @@ -267,8 +268,9 @@ def run_decision_scenario( }}, rest: {{ actions: {{ - listWorkflowRuns: async () => {{ + listWorkflowRuns: async (request) => {{ workflowRunCalls += 1; + workflowRunRequests.push(request); if ( orderingFailurePending && scenario.orderingApiFailure === 'runs' @@ -374,6 +376,7 @@ def run_decision_scenario( warnings, waitCalls, workflowRunCalls, + workflowRunRequests, priorRunStatuses, uploadJobStatuses, producedArtifactName, From 20697872cea8967f30fc5b4018e94a8baa76dc08 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:50:24 -0700 Subject: [PATCH 18/23] test(ci): cover paginated TestFlight upload history --- tests/test_ios_testflight_main_push_filter.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 5f013a25c28c..0fb9968f15c0 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -603,6 +603,46 @@ def test_demo_history_skips_newer_internal_artifact() -> None: ] +def test_demo_history_paginates_to_matching_artifact() -> None: + first_page = tuple( + ( + f"internal-sha-{index}", + "ios-testflight-build-metadata", + ) + for index in range(100) + ) + result = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[1], + input_variant="", + prior_upload_pages=( + first_page, + (("demo-base-sha", "ios-testflight-build-metadata-demo"),), + ), + prior_run_ids=tuple(range(1_000, 899, -1)), + changed_files=("docs/cli-contract.md",), + ) + + assert result["outputs"] == { + "should_build": "false", + "last_uploaded_sha": "demo-base-sha", + "variant": "demo", + } + assert [ + request.get("page") + for request in result["workflowRunRequests"] + if "page" in request + ] == [1, 2] + assert result["compareCalls"] == [ + { + "owner": "manaflow-ai", + "repo": "cmux", + "base": "demo-base-sha", + "head": "head-sha", + } + ] + + def test_manual_demo_dispatch_builds_even_when_head_already_uploaded() -> None: result = run_decision_scenario( event_name="workflow_dispatch", @@ -900,6 +940,7 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_schedule_decision_routes_demo_cron_to_demo_history() test_schedule_decision_routes_internal_cron_to_internal_history() test_demo_history_skips_newer_internal_artifact() + test_demo_history_paginates_to_matching_artifact() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() test_scheduled_run_waits_for_an_earlier_upload() test_scheduled_run_waits_before_upload_job_exists() From f0d03919a459a03d474c4e47045506d99f042bcf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:50:54 -0700 Subject: [PATCH 19/23] test(ci): model paginated TestFlight history --- tests/test_ios_testflight_main_push_filter.py | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 0fb9968f15c0..d2038dc1b2f3 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -168,6 +168,10 @@ def run_decision_scenario( prior_event: str = "schedule", prior_artifact: str = "ios-testflight-build-metadata", prior_uploads: tuple[tuple[str, str], ...] = (), + prior_upload_pages: tuple[ + tuple[tuple[str, str], ...], + ..., + ] = (), prior_run_ids: tuple[int, ...] = (), head_sha: str = "head-sha", changed_files: tuple[str, ...] = (), @@ -182,37 +186,51 @@ def run_decision_scenario( workflow_text(), "ios-testflight-build-metadata-override", ) - assert not ( - prior_sha and prior_uploads - ), "use prior_sha or prior_uploads, not both" + history_sources = sum( + bool(source) + for source in (prior_sha, prior_uploads, prior_upload_pages) + ) + assert history_sources <= 1, "use only one prior upload source" assert ordering_api_failure in ( None, "runs", "jobs", ), "ordering_api_failure must be runs or jobs" - upload_history = prior_uploads - if prior_sha: - upload_history = ((prior_sha, prior_artifact),) + upload_pages = prior_upload_pages + if not upload_pages: + upload_history = prior_uploads + if prior_sha: + upload_history = ((prior_sha, prior_artifact),) + upload_pages = (upload_history,) if upload_history else () + upload_history = tuple( + upload for page in upload_pages for upload in page + ) assert not prior_run_ids or len(prior_run_ids) == len( upload_history ), "prior_run_ids must match the upload history" run_ids = prior_run_ids or tuple( 50 - index for index in range(len(upload_history)) ) - prior_runs = [ - { - "id": run_id, - "sha": sha, - "artifact": artifact, - "event": prior_event, - } - for (sha, artifact), run_id in zip(upload_history, run_ids) - ] + prior_run_pages = [] + run_index = 0 + for page in upload_pages: + page_runs = [] + for sha, artifact in page: + page_runs.append( + { + "id": run_ids[run_index], + "sha": sha, + "artifact": artifact, + "event": prior_event, + } + ) + run_index += 1 + prior_run_pages.append(page_runs) scenario = { "eventName": event_name, "schedule": schedule, "inputVariant": input_variant, - "priorRuns": prior_runs, + "priorRunPages": prior_run_pages, "headSha": head_sha, "changedFiles": changed_files, "blockingPriorRun": blocking_prior_run, @@ -234,15 +252,21 @@ def run_decision_scenario( ? (scenario.uploadJobStartsLate ? 0 : 1) : 2; let orderingFailurePending = scenario.orderingApiFailure !== null; -const priorRuns = () => scenario.priorRuns.map((run, index) => ({{ +const allPriorRuns = scenario.priorRunPages.flat(); +const firstPriorRunId = allPriorRuns[0]?.id; +const priorRuns = (request) => {{ + const page = Number(request.page ?? 1); + const pageRuns = scenario.priorRunPages[page - 1] ?? []; + return pageRuns.map((run) => ({{ id: run.id, status: - scenario.blockingPriorRun && index === 0 + scenario.blockingPriorRun && run.id === firstPriorRunId ? 'in_progress' : 'completed', event: run.event, head_sha: run.sha, -}})); + }})); +}}; const setTimeout = (resolve, milliseconds) => {{ waitCalls.push(milliseconds); uploadPhase = Math.min(uploadPhase + 1, 2); @@ -278,7 +302,7 @@ def run_decision_scenario( orderingFailurePending = false; throw new Error('transient runs failure'); }} - const workflowRuns = priorRuns(); + const workflowRuns = priorRuns(request); priorRunStatuses.push(...workflowRuns.map((run) => run.status)); return {{ data: {{ workflow_runs: workflowRuns }}, @@ -292,12 +316,12 @@ def run_decision_scenario( orderingFailurePending = false; throw new Error('transient jobs failure'); }} - const priorRun = scenario.priorRuns.find( + const priorRun = allPriorRuns.find( (run) => run.id === Number(request.run_id) ); const isBlockingRun = scenario.blockingPriorRun && - priorRun?.id === scenario.priorRuns[0]?.id; + priorRun?.id === firstPriorRunId; const phase = isBlockingRun ? uploadPhase : 2; if (phase === 0) {{ uploadJobStatuses.push(null); @@ -316,7 +340,7 @@ def run_decision_scenario( }}; }}, listWorkflowRunArtifacts: async (request) => {{ - const priorRun = scenario.priorRuns.find( + const priorRun = allPriorRuns.find( (run) => run.id === Number(request.run_id) ); return {{ From b0131670af81f19e3e40a8ba056e61b6efe17fb3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:54:42 -0700 Subject: [PATCH 20/23] test(ci): cover TestFlight override artifact isolation --- tests/test_ios_testflight_main_push_filter.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index d2038dc1b2f3..001a7fa1a87f 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -684,6 +684,30 @@ def test_manual_demo_dispatch_builds_even_when_head_already_uploaded() -> None: assert result["compareCalls"] == [] +def test_manual_override_artifact_is_excluded_from_canonical_history() -> None: + override_run = run_decision_scenario( + event_name="workflow_dispatch", + marketing_version_override="1.2.3", + ) + produced_artifact = override_run["producedArtifactName"] + assert produced_artifact == "ios-testflight-build-metadata-override" + + scheduled_run = run_decision_scenario( + event_name="schedule", + schedule=IOS_SCHEDULES[0], + prior_sha="override-sha", + prior_artifact=produced_artifact, + changed_files=("docs/cli-contract.md",), + ) + + assert scheduled_run["outputs"] == { + "should_build": "true", + "last_uploaded_sha": "", + "variant": "internal", + } + assert scheduled_run["compareCalls"] == [] + + def test_scheduled_run_waits_for_an_earlier_upload() -> None: result = run_decision_scenario( event_name="schedule", @@ -966,6 +990,7 @@ def test_automatic_lane_stays_on_cmux_internal_identity() -> None: test_demo_history_skips_newer_internal_artifact() test_demo_history_paginates_to_matching_artifact() test_manual_demo_dispatch_builds_even_when_head_already_uploaded() + test_manual_override_artifact_is_excluded_from_canonical_history() test_scheduled_run_waits_for_an_earlier_upload() test_scheduled_run_waits_before_upload_job_exists() test_ordering_retries_transient_api_failures() From 8b6cf0838c9ed8737d74ac2cb59fa3a4bef13b83 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:54:56 -0700 Subject: [PATCH 21/23] test(ci): model TestFlight override input --- tests/test_ios_testflight_main_push_filter.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 001a7fa1a87f..7bc0c56f7b5b 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -164,6 +164,7 @@ def run_decision_scenario( event_name: str, schedule: Optional[str] = None, input_variant: str = "internal", + marketing_version_override: str = "", prior_sha: Optional[str] = None, prior_event: str = "schedule", prior_artifact: str = "ios-testflight-build-metadata", @@ -230,6 +231,7 @@ def run_decision_scenario( "eventName": event_name, "schedule": schedule, "inputVariant": input_variant, + "marketingVersionOverride": marketing_version_override, "priorRunPages": prior_run_pages, "headSha": head_sha, "changedFiles": changed_files, @@ -277,7 +279,10 @@ def run_decision_scenario( eventName: scenario.eventName, payload: {{ schedule: scenario.schedule, - inputs: {{ variant: scenario.inputVariant }}, + inputs: {{ + marketing_version_override: scenario.marketingVersionOverride, + variant: scenario.inputVariant, + }}, }}, ref: 'refs/heads/main', runId: 100, @@ -286,7 +291,7 @@ def run_decision_scenario( const github = {{ event: {{ inputs: {{ - marketing_version_override: '', + marketing_version_override: scenario.marketingVersionOverride, variant: scenario.inputVariant, }}, }}, From e4df983a891b6f23cd405883ec844a5549d2aaf8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:59:07 -0700 Subject: [PATCH 22/23] test(ci): exercise TestFlight concurrency mapping --- tests/test_ios_testflight_main_push_filter.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index 7bc0c56f7b5b..c178f91150f5 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -911,13 +911,23 @@ def test_testflight_notes_use_the_same_ios_path_contract() -> None: def test_scheduled_and_manual_runs_use_independent_concurrency_groups() -> None: text = workflow_text() + concurrency = scalar_mapping( + mapping_block(text, "concurrency", indent=0), + indent=2, + ) + group_template = concurrency["group"] + run_id_token = "${{ github.run_id }}" - assert "group: ios-testflight-${{ github.run_id }}" in text - assert "github.event_name == 'push' && github.sha" not in text - assert "group: ios-testflight-${{ github.ref_name }}" not in text - assert "cancel-in-progress: false" in text - assert "ios-testflight-assignment-state-complete" not in text - assert "CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE" not in text + assert concurrency == { + "group": "ios-testflight-${{ github.run_id }}", + "cancel-in-progress": "false", + } + assert group_template.count(run_id_token) == 1 + scheduled_group = group_template.replace(run_id_token, "100") + manual_group = group_template.replace(run_id_token, "101") + assert scheduled_group == "ios-testflight-100" + assert manual_group == "ios-testflight-101" + assert scheduled_group != manual_group def test_automatic_lane_stays_on_cmux_internal_identity() -> None: From 131a12974c7179e0128902ae02b0975832c2cd2b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:59:24 -0700 Subject: [PATCH 23/23] test(ci): parse TestFlight concurrency config --- tests/test_ios_testflight_main_push_filter.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_ios_testflight_main_push_filter.py b/tests/test_ios_testflight_main_push_filter.py index c178f91150f5..0044b1caddd2 100644 --- a/tests/test_ios_testflight_main_push_filter.py +++ b/tests/test_ios_testflight_main_push_filter.py @@ -108,6 +108,20 @@ def mapping_keys(text: str, indent: int) -> tuple[str, ...]: return tuple(keys) +def scalar_mapping(text: str, indent: int) -> dict[str, str]: + values = {} + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if len(line) - len(line.lstrip()) != indent: + continue + key, separator, value = line.strip().partition(":") + assert separator and value.strip(), f"invalid scalar mapping: {line}" + assert key not in values, f"duplicate scalar key: {key}" + values[key] = value.strip() + return values + + def test_literal_block_accepts_whitespace_only_lines() -> None: text = " script: |\n first\n \n second\nnext:\n"