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
34 changes: 14 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.filter.outputs.docs_only }}
ios_required: ${{ steps.filter.outputs.ios_required }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -68,17 +68,7 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- . \
':(exclude)*.md' \
':(exclude)**/*.md'; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
else
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
python3 scripts/ci-scope.py --event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT"

secrets:
name: Secret Patterns
Expand All @@ -91,6 +81,8 @@ jobs:

formatting:
name: Swift Format
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.ios_required != 'false' }}
runs-on: macos-26
steps:
- uses: actions/checkout@v7
Expand All @@ -106,6 +98,8 @@ jobs:

lint:
name: SwiftLint
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.ios_required != 'false' }}
runs-on: macos-26
steps:
- uses: actions/checkout@v7
Expand All @@ -121,7 +115,7 @@ jobs:
build-test:
name: Build & Test
needs: [changes, release-tools, formatting, lint]
if: needs.changes.outputs.docs_only != 'true'
if: ${{ !cancelled() && needs.changes.outputs.ios_required != 'false' && needs.release-tools.result == 'success' && needs.formatting.result == 'success' && needs.lint.result == 'success' }}
runs-on: macos-26
steps:
- uses: actions/checkout@v7
Expand Down Expand Up @@ -173,7 +167,7 @@ jobs:
ui-tests:
name: UI Tests — ${{ matrix.name }}
needs: [changes, build-test]
if: needs.changes.outputs.docs_only != 'true'
if: ${{ !cancelled() && needs.changes.outputs.ios_required != 'false' && needs.build-test.result == 'success' }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -242,7 +236,7 @@ jobs:
run: scripts/run-ui-journeys.sh
- name: Export evidence for Linux review
id: export
if: always() && steps.evidence.outputs.directory != ''
if: ${{ !cancelled() && steps.evidence.outputs.directory != '' }}
env:
ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }}
run: |
Expand All @@ -256,7 +250,7 @@ jobs:
> "$ARTIFACT_DIR/tests.json"
fi
- name: Create portable evidence report
if: always() && steps.evidence.outputs.directory != ''
if: ${{ !cancelled() && steps.evidence.outputs.directory != '' }}
env:
ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }}
JOURNEY_OUTCOME: ${{ steps.journeys.outcome }}
Expand All @@ -269,15 +263,15 @@ jobs:
python3 scripts/ui-evidence-report.py "$ARTIFACT_DIR"
- name: Retain video, screenshots, differences and test results
id: upload
if: always() && steps.evidence.outputs.directory != ''
if: ${{ !cancelled() && steps.evidence.outputs.directory != '' }}
uses: actions/upload-artifact@v7
with:
name: ui-${{ matrix.device }}-${{ matrix.runtime }}-attempt-${{ github.run_attempt }}
path: ${{ steps.evidence.outputs.directory }}
if-no-files-found: error
retention-days: 14
- name: Link evidence in job summary
if: always() && steps.upload.outputs.artifact-url != ''
if: ${{ !cancelled() && steps.upload.outputs.artifact-url != '' }}
env:
ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
Expand All @@ -294,10 +288,10 @@ jobs:
echo 'Extract the artifact and open index.html. On Linux, run scripts/pr-evidence.sh PR_NUMBER.'
} >> "$GITHUB_STEP_SUMMARY"
- name: Shut down test simulator
if: always() && steps.simulator.outputs.udid != ''
if: ${{ !cancelled() && steps.simulator.outputs.udid != '' }}
run: xcrun simctl shutdown "${{ steps.simulator.outputs.udid }}" 2>/dev/null || true
- name: Remove temporary evidence
if: always() && steps.evidence.outputs.directory != ''
if: ${{ !cancelled() && steps.evidence.outputs.directory != '' }}
env:
ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }}
run: rm -rf "$ARTIFACT_DIR"
46 changes: 45 additions & 1 deletion docs/remote-ios-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,49 @@ Glass comparisons remain pinned to iOS 26.5 / iPhone 17 Pro / arm64. iOS 18.5 /
iPhone 16 checks behavior without comparing Glass baselines. Missing baselines
fail; CI never records new baselines.

## Fast checks for tooling changes

Run focused checks locally while editing the App Store uploader:

```bash
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.tests.test_app_store_metadata
```

Before pushing the finished change, run the script suite once with `ffmpeg` and
`ffprobe` installed:

```bash
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests
```

CI's Detect Changes job skips Swift Format, SwiftLint, Build & Test and both UI
jobs when every changed path is one of:

- `scripts/app-store-metadata.py` or `scripts/tests/test_app_store_metadata.py`;
- Markdown or HTML under `docs/`, or JSON under `docs/releases/`;
- PNGs under `marketing/app-store/screenshots/`, or that directory's `source.json`;
- root `AGENTS.md`, `CLAUDE.md` or `README.md`.

Release Tools, Evidence Tools and Secret Patterns still run. App inputs, the
simulator harness, workflows, the classifier itself and unknown paths get full
iOS validation. Renames check both paths; deleted app inputs also require iOS.
Manual `workflow_dispatch` always runs the full suite. These rules scope `ci.yml`;
the separate App Store screenshot capture workflow keeps its own triggers.

Use the automatic CI run for the latest push. Request another run only for a
concrete failure or when a skipped change needs simulator evidence; batch locally
verified fixes into a push instead of repeatedly restarting the same suite.

UI jobs export, report and upload evidence after both successful and failed tests.
On cancellation, `!cancelled()` skips this remaining work, the job summary and
simulator/temp cleanup, so the replaced run can release its concurrency slot.
Cancelled jobs use disposable GitHub-hosted macOS VMs; cleanup still runs after
ordinary test failures. Cancelled runs may have no complete evidence artifact;
review evidence from the replacement run. See GitHub's
[status check functions](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions),
[cancellation behavior](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation)
and [hosted runners](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners).

## Review a pull request from Linux

Push the PR branch, then run from the repository with authenticated `gh`, `jq` and `python3`:
Expand All @@ -26,7 +69,8 @@ the artifact's source commit/run/attempt and rechecks the PR head after waiting
downloading. It exits nonzero when CI fails, while retaining available evidence
for diagnosis. A missing run/artifact is an error, not a successful validation.
Downloads rejected by source validation and partial downloads are removed automatically.
Documentation-only PRs skip simulator jobs and have no UI evidence to download.
Changes limited to the non-iOS paths above skip simulator jobs and have no UI
evidence to download.
Artifact names include the CI attempt, so a rerun cannot accidentally download
first-attempt evidence with the same name. To refresh evidence, rerun the full
workflow with `gh run rerun RUN_ID`; rerunning only individual jobs can leave the
Expand Down
53 changes: 53 additions & 0 deletions scripts/ci-scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Skip iOS checks only for changes confined to known standalone tooling and docs."""

import argparse
from pathlib import PurePosixPath
import re
import subprocess


NON_IOS_FILES = {
"AGENTS.md", "CLAUDE.md", "README.md",
"scripts/app-store-metadata.py", "scripts/tests/test_app_store_metadata.py",
}


def non_ios_file(path):
file = PurePosixPath(path)
if path in NON_IOS_FILES:
return True
if path.startswith("docs/"):
return file.suffix in {".md", ".html"} or (path.startswith("docs/releases/") and file.suffix == ".json")
if path.startswith("marketing/app-store/screenshots/"):
return file.suffix == ".png" or path == "marketing/app-store/screenshots/source.json"
return False


def ios_required(event, base, head):
if event not in {"pull_request", "push"} or not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)):
return True
if base == "0" * 40:
return True
revisions = [f"{base}...{head}"] if event == "pull_request" else [base, head]
try:
# Both sides of a rename must be checked, including deleted app inputs.
result = subprocess.run(["git", "diff", "--name-only", "--no-renames", "-z", *revisions, "--"],
check=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
files = result.stdout.decode("utf-8").rstrip("\0").split("\0")
except (OSError, subprocess.CalledProcessError, UnicodeDecodeError):
return True
return not all(non_ios_file(path) for path in files)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--event", required=True)
parser.add_argument("--base", default="")
parser.add_argument("--head", default="")
args = parser.parse_args()
print(f"ios_required={str(ios_required(args.event, args.base, args.head)).lower()}")


if __name__ == "__main__":
main()
115 changes: 115 additions & 0 deletions scripts/tests/test_ci_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest


SCRIPT = Path(__file__).resolve().parents[1] / "ci-scope.py"


class CIScopeTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="growingup-ci-scope-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.git("init", "-q")
self.git("config", "user.email", "fixture@example.invalid")
self.git("config", "user.name", "Fixture")
for path in ("scripts/app-store-metadata.py", "scripts/tests/test_app_store_metadata.py",
"GrowingUp/App.swift", "docs/guide.md"):
self.write(path, "base\n")
self.base = self.commit()

def git(self, *args):
return subprocess.run(["git", *args], cwd=self.root, check=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE).stdout.decode().strip()

def write(self, path, content="changed\n"):
file = self.root / path
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(content)

def commit(self):
self.git("add", ".")
self.git("commit", "-qm", "fixture")
return self.git("rev-parse", "HEAD")

def classify(self, event="pull_request", base=None, head=None):
result = subprocess.run([sys.executable, str(SCRIPT), "--event", event,
"--base", self.base if base is None else base,
"--head", self.git("rev-parse", "HEAD") if head is None else head],
cwd=self.root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.stdout.decode().strip()

def test_uploader_tests_docs_and_committed_screenshots_need_only_tool_checks(self):
for path in ("scripts/app-store-metadata.py", "scripts/tests/test_app_store_metadata.py",
"docs/guide.md", "docs/app-store-screenshots-preview.html", "docs/releases/notes.json",
"marketing/app-store/screenshots/ru/01-mia.png", "marketing/app-store/screenshots/source.json"):
self.write(path)
self.commit()
for event in ("pull_request", "push"):
with self.subTest(event=event):
self.assertEqual(self.classify(event), "ios_required=false")

def test_app_assets_project_packages_tests_harness_workflows_and_unknown_paths_need_ios(self):
for path in ("Core/Person.swift", "GrowingUp/Assets.xcassets/icon.png", "GrowingUp.xcodeproj/project.pbxproj",
"GrowingUp.xcodeproj/xcshareddata/swiftpm/Package.resolved", "GrowingUpTests/PersonTests.swift",
"GrowingUpUITests/Baselines/glass.png", "scripts/run-ui-journeys.sh",
"scripts/run-app-store-screenshots.sh", "scripts/ci-scope.py", "scripts/tests/test_ci_scope.py",
".github/workflows/ci.yml", "docs/app-store-demo-photos/baby-girl.png", "unknown/tool.py",
"marketing/app-store/screenshots/new.swift", "Gemfile", ".swiftlint.yml"):
with self.subTest(path=path):
self.git("reset", "--hard", self.base)
self.write(path)
self.commit()
self.assertEqual(self.classify(), "ios_required=true")

def test_mixed_tool_and_app_changes_need_ios(self):
self.write("scripts/app-store-metadata.py")
self.write("GrowingUp/App.swift")
self.commit()
self.assertEqual(self.classify(), "ios_required=true")

def test_deleting_app_input_needs_ios(self):
(self.root / "GrowingUp/App.swift").unlink()
self.commit()
self.assertEqual(self.classify(), "ios_required=true")

def test_both_sides_of_renames_are_classified(self):
for old, new in (("GrowingUp/App.swift", "docs/archived.md"), ("docs/guide.md", "GrowingUp/New.swift")):
with self.subTest(old=old, new=new):
self.git("reset", "--hard", self.base)
self.git("mv", old, new)
self.commit()
self.assertEqual(self.classify(), "ios_required=true")

def test_docs_rename_stays_on_tool_checks(self):
self.git("mv", "docs/guide.md", "docs/renamed guide.md")
self.commit()
self.assertEqual(self.classify(), "ios_required=false")

def test_manual_unknown_events_missing_or_zero_base_and_unreadable_refs_need_ios(self):
self.write("scripts/app-store-metadata.py")
self.commit()
for event, base in (("workflow_dispatch", self.base), ("unknown", self.base),
("push", ""), ("push", "0" * 40), ("push", "f" * 40)):
with self.subTest(event=event, base=base):
self.assertEqual(self.classify(event, base), "ios_required=true")

def test_pr_compares_branch_changes_and_push_compares_exact_commits(self):
self.git("checkout", "-qb", "tooling")
self.write("scripts/app-store-metadata.py")
head = self.commit()
self.git("checkout", "--detach", self.base)
self.write("GrowingUp/App.swift")
base_tip = self.commit()
self.assertEqual(self.classify("pull_request", base_tip, head), "ios_required=false")
self.assertEqual(self.classify("push", base_tip, head), "ios_required=true")

def test_empty_diff_fails_closed(self):
self.assertEqual(self.classify(), "ios_required=true")


if __name__ == "__main__":
unittest.main()
Loading