Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ jobs:
- name: Validate TestFlight notes generator
run: python3 tests/test_ios_testflight_notes.py

- name: Validate CMUX INTERNAL main-push path filter
run: python3 tests/test_ios_testflight_main_push_filter.py

- name: Validate external TestFlight group assignment helper
run: python3 tests/test_ios_testflight_external_distribution.py

Expand Down
27 changes: 20 additions & 7 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
name: iOS TestFlight (CMUX INTERNAL)

on:
# 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.
# Automatically upload only when main changes the iOS app or a direct archive
# input. Manual dispatch remains available for intentional rebuilds.
push:
branches:
- main
paths:
- "ios/**"
- "Packages/iOS/**"
- "Packages/Shared/**"
- "Sources/Mobile/**"
- "vendor/stack-auth-swift-sdk-prerelease/**"
- "ghostty"
- "ghostty.h"
- "scripts/ensure-ghosttykit.sh"
- "scripts/ghosttykit-checksums.txt"
- "scripts/install-zig-ci.sh"
- "scripts/validate-xcframework-archive.py"
- ".github/workflows/ios-testflight.yml"
workflow_dispatch:
inputs:
build_number:
Expand All @@ -20,8 +32,8 @@ on:

concurrency:
# 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.
# SHAs during merge bursts. Key qualifying push runs by SHA so every iOS change
# survives. Manual runs use run_id because an operator may intentionally rebuild.
group: ios-testflight-${{ github.event_name == 'push' && github.sha || github.run_id }}
cancel-in-progress: false

Expand Down Expand Up @@ -181,8 +193,9 @@ jobs:
core.warning(`could not resolve last uploaded sha: ${e.message}`);
}

// Push and manual runs always build. There is deliberately no SHA or
// path gate: every main push owns one CMUX INTERNAL upload.
// Push runs have already passed the workflow's iOS path filter.
// Manual runs are intentional rebuilds, so every event that reaches
// this workflow should build.
const shouldBuild =
context.eventName === 'push' || context.eventName === 'workflow_dispatch';
core.setOutput('should_build', shouldBuild ? 'true' : 'false');
Expand Down
2 changes: 1 addition & 1 deletion ios/scripts/generate-testflight-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ fi

# iOS-affecting paths: mirror the push-trigger filter in ios-testflight.yml so the
# notes only mention changes that could be in this build.
PATHS="ios Packages/iOS Packages/Shared Sources/Mobile vendor/stack-auth-swift-sdk-prerelease scripts/ghosttykit-checksums.txt"
PATHS="ios Packages/iOS Packages/Shared Sources/Mobile vendor/stack-auth-swift-sdk-prerelease ghostty ghostty.h scripts/ensure-ghosttykit.sh scripts/ghosttykit-checksums.txt scripts/install-zig-ci.sh scripts/validate-xcframework-archive.py .github/workflows/ios-testflight.yml"

# First-line subjects of non-merge commits in range touching those paths. Squash
# merges carry the PR title + "(#N)" as the subject, which is exactly what we want.
Expand Down
56 changes: 0 additions & 56 deletions tests/test_ios_testflight_every_main_push.py

This file was deleted.

146 changes: 146 additions & 0 deletions tests/test_ios_testflight_main_push_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import shlex
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / ".github" / "workflows" / "ios-testflight.yml"
NOTES_GENERATOR = ROOT / "ios" / "scripts" / "generate-testflight-notes.sh"
IOS_PATHS = (
"ios/**",
"Packages/iOS/**",
"Packages/Shared/**",
"Sources/Mobile/**",
"vendor/stack-auth-swift-sdk-prerelease/**",
"ghostty",
"ghostty.h",
"scripts/ensure-ghosttykit.sh",
"scripts/ghosttykit-checksums.txt",
"scripts/install-zig-ci.sh",
"scripts/validate-xcframework-archive.py",
".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 mapping_block(text: str, key: str, indent: int) -> str:
marker = f"{' ' * indent}{key}:\n"
assert marker in text, f"missing {key} mapping"
lines = text[text.index(marker) + len(marker) :].splitlines()
block = []
for line in lines:
if line.strip() and len(line) - len(line.lstrip()) <= indent:
break
block.append(line)
return "\n".join(block)


def mapping_keys(text: str, indent: int) -> tuple[str, ...]:
keys = []
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if len(line) - len(line.lstrip()) != indent:
continue
key, separator, _ = line.strip().partition(":")
assert separator, f"invalid mapping entry: {line}"
parsed = shlex.split(key, comments=True)
assert len(parsed) == 1, f"invalid mapping key: {line}"
keys.append(parsed[0])
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)}- "
values = []
for line in lines:
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}"
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:
text = workflow_text()
triggers = trigger_block(text)
push = mapping_block(triggers, "push", indent=2)

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert "context.eventName === 'push' || context.eventName === 'workflow_dispatch'" in text


def test_mapping_keys_normalizes_quoted_yaml_keys() -> None:
triggers = " push:\n 'schedule':\n \"workflow_dispatch\":\n"

assert mapping_keys(triggers, indent=2) == (
"push",
"schedule",
"workflow_dispatch",
)


def test_testflight_notes_use_the_same_ios_path_contract() -> None:
generator = NOTES_GENERATOR.read_text(encoding="utf-8")
path_assignment = next(
(line for line in generator.splitlines() if line.startswith("PATHS=")),
None,
)
assert path_assignment is not None, "missing PATHS assignment"
notes_paths = tuple(
shlex.split(path_assignment.removeprefix("PATHS="))[0].split()
Comment thread
azooz2003-bit marked this conversation as resolved.
)
expected_notes_paths = tuple(
path.removesuffix("/**") for path in IOS_PATHS
)

assert notes_paths == expected_notes_paths


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_main_push_triggers_only_for_ios_affecting_paths()
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_automatic_lane_stays_on_cmux_internal_identity()
print("all iOS TestFlight main-push filter tests passed")