From efc52a215373d84439677ea22bca99ca2d94dcad Mon Sep 17 00:00:00 2001 From: Danis Ziganshin Date: Sat, 10 Oct 2026 15:34:31 +0300 Subject: [PATCH] Skip iOS CI for standalone App Store tooling and marketing changes --- .github/workflows/ci.yml | 34 ++++------ docs/remote-ios-verification.md | 46 ++++++++++++- scripts/ci-scope.py | 53 +++++++++++++++ scripts/tests/test_ci_scope.py | 115 ++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 scripts/ci-scope.py create mode 100644 scripts/tests/test_ci_scope.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821f48c3..b467972d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 @@ -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 @@ -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 @@ -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 @@ -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: @@ -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: | @@ -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 }} @@ -269,7 +263,7 @@ 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 }} @@ -277,7 +271,7 @@ jobs: 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 }} @@ -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" diff --git a/docs/remote-ios-verification.md b/docs/remote-ios-verification.md index be96774c..3cfbe18c 100644 --- a/docs/remote-ios-verification.md +++ b/docs/remote-ios-verification.md @@ -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`: @@ -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 diff --git a/scripts/ci-scope.py b/scripts/ci-scope.py new file mode 100644 index 00000000..22e61c96 --- /dev/null +++ b/scripts/ci-scope.py @@ -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() diff --git a/scripts/tests/test_ci_scope.py b/scripts/tests/test_ci_scope.py new file mode 100644 index 00000000..0d736d75 --- /dev/null +++ b/scripts/tests/test_ci_scope.py @@ -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()