From 5e4d8d8842fe151934d1fcdabf3bb0c3b9cf0465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:59:12 +0200 Subject: [PATCH] Optimize iOS CI workflows --- .github/workflows/codeql.yml | 129 ++++----- .github/workflows/ios-app-store.yml | 17 +- .github/workflows/ios-ci.yml | 271 ++++++++++++++++++- .github/workflows/ios-testflight.yml | 33 ++- Symi/Sources/Models/ModelSchema.swift | 4 +- SymiTests/SwiftDataMigrationTests.swift | 195 +++++++++++++ SymiUITests/HomeRedesignUITests.swift | 15 + SymiUITests/SymiScreenshotTests.swift | 18 ++ docs/Teststrategie-und-Release-Checkliste.md | 37 ++- docs/Xcode-Cloud.md | 33 ++- fastlane/Fastfile | 64 ++++- fastlane/Snapfile | 2 +- 12 files changed, 673 insertions(+), 145 deletions(-) create mode 100644 SymiTests/SwiftDataMigrationTests.swift diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 76fccf5..497c387 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,74 +1,75 @@ -# removed for now +name: CodeQL -# name: CodeQL +on: + push: + branches: + - main + paths: + - "Symi/**" + - "SymiTests/**" + - "Symi.xcodeproj/**" + - ".github/workflows/codeql.yml" + pull_request: + branches: + - main + paths: + - "Symi/**" + - "SymiTests/**" + - "Symi.xcodeproj/**" + - ".github/workflows/codeql.yml" + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: -# on: -# push: -# branches: -# - main -# paths: -# - 'Symi/**' -# - 'SymiTests/**' -# - 'Symi.xcodeproj/**' -# - '.github/workflows/codeql.yml' -# pull_request: -# branches: -# - main -# paths: -# - 'Symi/**' -# - 'SymiTests/**' -# - 'Symi.xcodeproj/**' -# - '.github/workflows/codeql.yml' -# schedule: -# - cron: '23 4 * * 1' -# workflow_dispatch: +jobs: + analyze: + name: Analyze (Swift) + runs-on: macos-26 + timeout-minutes: 45 + permissions: + actions: read + contents: read + security-events: write + env: + CODEQL_DESTINATION: platform=macOS,arch=arm64,variant=Mac Catalyst -# jobs: -# analyze: -# name: Analyze (Swift) -# runs-on: macos-26 -# timeout-minutes: 45 -# permissions: -# actions: read -# contents: read -# security-events: write -# env: -# CODEQL_DESTINATION: platform=macOS,arch=arm64,variant=Mac Catalyst + strategy: + fail-fast: false + matrix: + language: + - swift -# strategy: -# fail-fast: false -# matrix: -# language: -# - swift + steps: + - name: Checkout + uses: actions/checkout@v6 -# steps: -# - name: Checkout -# uses: actions/checkout@v6 + - name: Xcode 26.4 auswählen + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.4" -# - name: Xcode 26.4 auswählen -# uses: maxim-lobanov/setup-xcode@v1 -# with: -# xcode-version: '26.4' + - name: Xcode-Version ausgeben + run: | + xcodebuild -version + xcode-select -p -# - name: CodeQL initialisieren -# uses: github/codeql-action/init@v4 -# with: -# languages: ${{ matrix.language }} -# build-mode: manual + - name: CodeQL initialisieren + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: manual -# - name: Swift Packages auflösen -# run: xcodebuild -resolvePackageDependencies -project Symi.xcodeproj -scheme Symi + - name: Swift Packages auflösen + run: xcodebuild -resolvePackageDependencies -project Symi.xcodeproj -scheme Symi -# - name: App für CodeQL bauen -# run: | -# xcodebuild build \ -# -project Symi.xcodeproj \ -# -scheme Symi \ -# -destination "${CODEQL_DESTINATION}" \ -# -derivedDataPath "${RUNNER_TEMP}/DerivedData" \ -# CODE_SIGNING_ALLOWED=NO + - name: App für CodeQL bauen + run: | + xcodebuild build \ + -project Symi.xcodeproj \ + -scheme Symi \ + -destination "${CODEQL_DESTINATION}" \ + -derivedDataPath "${RUNNER_TEMP}/DerivedData" \ + CODE_SIGNING_ALLOWED=NO -# - name: CodeQL analysieren -# uses: github/codeql-action/analyze@v4 - -null + - name: CodeQL analysieren + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/ios-app-store.yml b/.github/workflows/ios-app-store.yml index d218ee8..fca8a6b 100644 --- a/.github/workflows/ios-app-store.yml +++ b/.github/workflows/ios-app-store.yml @@ -5,6 +5,10 @@ on: tags: - 'v*' +concurrency: + group: app-store-release + cancel-in-progress: false + jobs: detect-release-changes: runs-on: ubuntu-latest @@ -27,7 +31,7 @@ jobs: printf 'Geänderte Dateien:\n%s\n' "${changed_files}" - if printf '%s\n' "${changed_files}" | grep -Eq '^(Symi/|SymiUITests/|Symi\.xcodeproj/|fastlane/|Gemfile$|Gemfile\.lock$|\.ruby-version$|\.github/workflows/ios-app-store\.yml$)'; then + if printf '%s\n' "${changed_files}" | grep -Eq '^(Symi/|SymiUITests/|Symi\.xcodeproj/|fastlane/|Gemfile$|Gemfile\.lock$|\.ruby-version$|docs/(Xcode-Cloud|Teststrategie-und-Release-Checkliste)\.md$|\.github/workflows/ios-app-store\.yml$)'; then echo "should_deploy=true" >> "$GITHUB_OUTPUT" else echo "should_deploy=false" >> "$GITHUB_OUTPUT" @@ -53,7 +57,6 @@ jobs: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_GIT_BRANCH: ${{ secrets.MATCH_GIT_BRANCH }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} - SCREENSHOT_IOS_VERSION: '26.4' SNAPSHOT_SIMULATOR_WAIT_FOR_BOOT_TIMEOUT: '10' FASTLANE_SKIP_UPDATE_CHECK: '1' CI: '1' @@ -63,6 +66,11 @@ jobs: with: fetch-depth: 0 + - name: Xcode 26.4 auswählen + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.4' + - name: Release-Secrets validieren run: | missing=() @@ -89,11 +97,6 @@ jobs: fi shell: bash - - name: Xcode 26.4 auswählen - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '26.4' - - name: Xcode-Version ausgeben run: | xcodebuild -version diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml index 1af9150..f23a519 100644 --- a/.github/workflows/ios-ci.yml +++ b/.github/workflows/ios-ci.yml @@ -7,8 +7,12 @@ on: paths: - "Symi/**" - "SymiTests/**" + - "SymiUITests/**" - "Symi.xcodeproj/**" - ".gitignore" + - ".swiftlint.yml" + - "fastlane/Fastfile" + - "fastlane/Snapfile" - ".github/workflows/ios-ci.yml" push: branches: @@ -16,23 +20,101 @@ on: paths: - "Symi/**" - "SymiTests/**" + - "SymiUITests/**" - "Symi.xcodeproj/**" - ".gitignore" + - ".swiftlint.yml" + - "fastlane/Fastfile" + - "fastlane/Snapfile" - ".github/workflows/ios-ci.yml" + workflow_dispatch: + +concurrency: + group: ios-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + SWIFTPM_CACHE_PATH: ${{ github.workspace }}/.swiftpm-cache jobs: - build-and-test: + changes: + name: Änderungen prüfen + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + iphone_tests: ${{ steps.filter.outputs.iphone_tests }} + ui_smoke: ${{ steps.filter.outputs.ui_smoke }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Relevante Änderungen bestimmen + id: filter + shell: bash + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "iphone_tests=true" >> "$GITHUB_OUTPUT" + echo "ui_smoke=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${{ github.event_name }}" == "push" ]]; then + echo "iphone_tests=true" >> "$GITHUB_OUTPUT" + echo "ui_smoke=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + base_sha="${{ github.event.pull_request.base.sha }}" + git fetch --no-tags --depth=1 origin "${base_sha}" + changed_files="$(git diff --name-only "${base_sha}" HEAD)" + + printf 'Geänderte Dateien:\n%s\n' "${changed_files}" + + if printf '%s\n' "${changed_files}" | grep -Eq '^(Symi/Sources/(Models|Infrastructure/Persistence|Infrastructure/Health|Core/Export|Features/Export|App/(SymiApp|StoreRecovery|StartupMaintenanceService)\.swift)|SymiTests/(SwiftDataMigrationTests|DataTransferHealthContextTests|PersistentStoreRecoveryTests)\.swift|Symi\.xcodeproj/|\.github/workflows/ios-ci\.yml$)'; then + echo "iphone_tests=true" >> "$GITHUB_OUTPUT" + else + echo "iphone_tests=false" >> "$GITHUB_OUTPUT" + fi + + if printf '%s\n' "${changed_files}" | grep -Eq '^(SymiUITests/|Symi/Sources/Features/(Home|Capture)/|Symi/Sources/App/ScreenshotSupport\.swift|Symi\.xcodeproj/|fastlane/(Fastfile|Snapfile)$|\.github/workflows/ios-ci\.yml$)'; then + echo "ui_smoke=true" >> "$GITHUB_OUTPUT" + else + echo "ui_smoke=false" >> "$GITHUB_OUTPUT" + fi + + lint: + name: SwiftLint runs-on: macos-26 - timeout-minutes: 30 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: SwiftLint installieren + run: brew install swiftlint + + - name: Design-Token-Regeln prüfen + run: swiftlint lint --config .swiftlint.yml + + catalyst-tests: + name: Unit-Tests (Catalyst) + runs-on: macos-26 + timeout-minutes: 25 permissions: contents: read env: APPLE_DEVELOPER_TEAM_ID: ${{ secrets.APPLE_DEVELOPER_TEAM_ID }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} TELEMETRY_APP_ID: ${{ secrets.TELEMETRY_APP_ID }} - RESULT_BUNDLE_PATH: ${{ github.workspace }}/artifacts/SymiTests.xcresult + RESULT_BUNDLE_PATH: ${{ github.workspace }}/artifacts/SymiTests-Catalyst.xcresult TEST_DESTINATION: platform=macOS,arch=arm64,variant=Mac Catalyst - SWIFTPM_CACHE_PATH: ${{ github.workspace }}/.swiftpm-cache steps: - name: Checkout uses: actions/checkout@v6 @@ -47,12 +129,6 @@ jobs: xcodebuild -version xcode-select -p - - name: SwiftLint installieren - run: brew install swiftlint - - - name: Design-Token-Regeln prüfen - run: swiftlint lint --config .swiftlint.yml - - name: Secrets prüfen run: | if [ -z "${APPLE_DEVELOPER_TEAM_ID}" ]; then @@ -94,7 +170,7 @@ jobs: -scheme SymiTests \ -clonedSourcePackagesDirPath "${SWIFTPM_CACHE_PATH}" \ -destination "${TEST_DESTINATION}" \ - -derivedDataPath "${RUNNER_TEMP}/DerivedData" \ + -derivedDataPath "${RUNNER_TEMP}/DerivedData-Catalyst" \ CODE_SIGNING_ALLOWED=NO - name: Tests ausführen @@ -104,14 +180,179 @@ jobs: -scheme SymiTests \ -clonedSourcePackagesDirPath "${SWIFTPM_CACHE_PATH}" \ -destination "${TEST_DESTINATION}" \ - -derivedDataPath "${RUNNER_TEMP}/DerivedData" \ + -derivedDataPath "${RUNNER_TEMP}/DerivedData-Catalyst" \ + -resultBundlePath "${RESULT_BUNDLE_PATH}" \ + CODE_SIGNING_ALLOWED=NO + + - name: xcresult bei Fehler hochladen + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ios-ci-SymiTests-Catalyst-xcresult + path: ${{ env.RESULT_BUNDLE_PATH }} + if-no-files-found: warn + + iphone-tests: + name: Unit-Tests (iPhone) + needs: + - changes + if: needs.changes.outputs.iphone_tests == 'true' + runs-on: macos-26 + timeout-minutes: 25 + permissions: + contents: read + env: + APPLE_DEVELOPER_TEAM_ID: ${{ secrets.APPLE_DEVELOPER_TEAM_ID }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + TELEMETRY_APP_ID: ${{ secrets.TELEMETRY_APP_ID }} + RESULT_BUNDLE_PATH: ${{ github.workspace }}/artifacts/SymiTests-iPhone.xcresult + TEST_DESTINATION: platform=iOS Simulator,name=iPhone 17 + TEST_DEVICE_NAME: iPhone 17 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Xcode 26.4 auswählen + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.4" + + - name: Xcode-Version ausgeben + run: | + xcodebuild -version + xcode-select -p + + - name: Secrets prüfen + run: | + if [ -z "${APPLE_DEVELOPER_TEAM_ID}" ]; then + echo "Das GitHub Secret APPLE_DEVELOPER_TEAM_ID fehlt." + exit 1 + fi + + - name: Lokale CI-Konfiguration erzeugen + run: | + mkdir -p artifacts + cat > Symi/Configs/LocalSecrets.xcconfig < Symi/Configs/LocalSecrets.xcconfig <()) + + #expect(episodes.count == 1) + guard let episode = episodes.first else { + Issue.record("Migrierte Episode fehlt.") + return + } + + #expect(episode.id == episodeID) + #expect(episode.intensity == 8) + #expect(episode.medications.first?.name == "Sumatriptan") + #expect(episode.weatherSnapshot?.id == weatherID) + #expect(episode.weatherSnapshot?.condition == "Regen") + #expect(episode.continuousMedicationChecks.isEmpty) + } + + @Test + func migratesV5StoreToCurrentSchemaAndPreservesWeatherContext() throws { + let storeURL = try makeStoreURL() + let episodeID = UUID(uuidString: "15500000-0000-0000-0000-000000000005")! + let startedAt = Date(timeIntervalSince1970: 1_720_000_000) + let contextRangeStart = startedAt.addingTimeInterval(-43_200) + let contextRangeEnd = startedAt.addingTimeInterval(129_600) + let contextPoint = WeatherContextPointData( + recordedAt: startedAt, + condition: "Bewölkt", + temperature: 19.2, + humidity: 68, + pressure: 1009, + precipitation: 0.3, + weatherCode: 3 + ) + + do { + let container = try makeContainer(schema: SymiSchemaV5.self, storeURL: storeURL) + let context = ModelContext(container) + let episode = SymiSchemaV5.Episode( + id: episodeID, + startedAt: startedAt, + updatedAt: startedAt.addingTimeInterval(90), + typeRaw: EpisodeType.headache.rawValue, + intensity: 5, + painLocation: "Schläfen", + painCharacter: "Drückend", + notes: "V5-Migration", + symptomsStorage: "Lichtempfindlichkeit", + triggersStorage: "Schlaf", + functionalImpact: "Langsamer Tag", + menstruationStatusRaw: MenstruationStatus.unknown.rawValue + ) + episode.weatherSnapshot = SymiSchemaV5.WeatherSnapshot( + recordedAt: startedAt, + temperature: 19.2, + condition: "Bewölkt", + humidity: 68, + pressure: 1009, + precipitation: 0.3, + weatherCode: 3, + source: "Apple Weather", + dayRangeStart: Calendar.current.startOfDay(for: startedAt), + dayRangeEnd: Calendar.current.startOfDay(for: startedAt).addingTimeInterval(86_400), + contextRangeStart: contextRangeStart, + contextRangeEnd: contextRangeEnd, + contextPointsStorage: WeatherSnapshot.encodeContextPoints([contextPoint]), + episode: episode + ) + + context.insert(episode) + try context.save() + } + + let migratedContainer = try makeCurrentContainer(storeURL: storeURL) + let context = ModelContext(migratedContainer) + let episodes = try context.fetch(FetchDescriptor()) + + #expect(episodes.count == 1) + let episode = try #require(episodes.first) + #expect(episode.id == episodeID) + #expect(episode.weatherSnapshot?.contextRangeStart == contextRangeStart) + #expect(episode.weatherSnapshot?.contextRangeEnd == contextRangeEnd) + #expect(episode.weatherSnapshot?.contextPoints == [contextPoint]) + #expect(episode.continuousMedicationChecks.isEmpty) + #expect(try context.fetch(FetchDescriptor()).isEmpty) + } +} + +private func makeStoreURL() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "SymiMigrationTests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appending(path: "Symi.store") +} + +private func makeContainer( + schema: SchemaType.Type, + storeURL: URL +) throws -> ModelContainer { + let schema = Schema(versionedSchema: SchemaType.self) + let configuration = ModelConfiguration("migration-test", schema: schema, url: storeURL, cloudKitDatabase: .none) + return try ModelContainer(for: schema, configurations: [configuration]) +} + +private func makeCurrentContainer(storeURL: URL) throws -> ModelContainer { + let schema = Schema(versionedSchema: SymiSchemaV6.self) + let configuration = ModelConfiguration("migration-test", schema: schema, url: storeURL, cloudKitDatabase: .none) + return try ModelContainer( + for: schema, + migrationPlan: SymiMigrationPlan.self, + configurations: [configuration] + ) +} diff --git a/SymiUITests/HomeRedesignUITests.swift b/SymiUITests/HomeRedesignUITests.swift index a4d77e2..d16eae8 100644 --- a/SymiUITests/HomeRedesignUITests.swift +++ b/SymiUITests/HomeRedesignUITests.swift @@ -48,6 +48,21 @@ final class HomeRedesignUITests: XCTestCase { XCTAssertFalse(app.tabBars.buttons["Heute"].exists) } + func testHomeQuickEntryCanSaveHeadacheOnlyEntry() { + let app = launchHome(extraArguments: ["-mt_disable_weather"]) + + XCTAssertTrue(app.descendants(matching: .any)["home-calendar"].waitForExistence(timeout: 6)) + app.descendants(matching: .any)["home-quick-entry"].tap() + + XCTAssertTrue(app.descendants(matching: .any)["entry-flow-step-headache"].waitForExistence(timeout: 6)) + XCTAssertTrue(app.descendants(matching: .any)["entry-intensity-card"].exists) + XCTAssertTrue(accessibilityElement(containing: "Wie stark ist es gerade?", in: app).exists) + + app.buttons["entry-flow-save-headache-only"].tap() + + XCTAssertTrue(app.alerts["Eintrag gespeichert"].waitForExistence(timeout: 6)) + } + private func launchHome(extraArguments: [String] = []) -> XCUIApplication { let app = XCUIApplication() app.launchArguments += [ diff --git a/SymiUITests/SymiScreenshotTests.swift b/SymiUITests/SymiScreenshotTests.swift index 0e1b517..5b4613e 100644 --- a/SymiUITests/SymiScreenshotTests.swift +++ b/SymiUITests/SymiScreenshotTests.swift @@ -56,6 +56,24 @@ final class SymiScreenshotTests: XCTestCase { assert(true) } + func testDefaultSeedShowsScaleFirstNewEntryFlow() throws { + let app = XCUIApplication() + setupSnapshot(app, waitForAnimations: false) + app.launchArguments += [ + "-mt_screenshot_screen", + "new-entry", + "-mt_screenshot_seed", + "default", + "-mt_disable_weather" + ] + app.launch() + + XCTAssertTrue(app.descendants(matching: .any)["entry-flow-step-headache"].waitForExistence(timeout: 6)) + XCTAssertTrue(app.descendants(matching: .any)["entry-intensity-card"].exists) + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Wie stark ist es gerade?")).firstMatch.exists) + XCTAssertTrue(app.buttons["entry-flow-save-headache-only"].exists) + } + private func waitForStableLayout() { RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.2)) } diff --git a/docs/Teststrategie-und-Release-Checkliste.md b/docs/Teststrategie-und-Release-Checkliste.md index b5a4f81..a4e7f18 100644 --- a/docs/Teststrategie-und-Release-Checkliste.md +++ b/docs/Teststrategie-und-Release-Checkliste.md @@ -15,18 +15,28 @@ Automatisierte Gates im Projekt: 1. Workflow `iOS CI` bei jedem `pull_request` auf `main` 2. Workflow `iOS CI` bei jedem `push` auf `main` -3. Build des Shared Scheme `Symi` -4. Ausführung von `SymiTests` -5. Upload des `xcresult` für nachvollziehbare Fehlerdiagnose in GitHub -6. Workflow `TestFlight Release` bei jedem `push` auf `main` für Distribution-Signing via `match`, Build via `build_app` und Verteilung via `pilot` -7. Workflow `App Store Release` bei Git-Tags `vX.Y.Z` für Screenshot-Erstellung, Distribution-Signing via `match` und Upload via `deliver` -8. Die finale Einreichung erfolgt manuell in App Store Connect über `Submit` +3. Swift-Builds, Tests und Release-Archive laufen in GitHub Actions mit Xcode 26.4 +4. schnelles PR-Gate aus SwiftLint/Design-Token-Regeln und `SymiTests` auf `Mac Catalyst` +5. Ausführung von `SymiTests` auf einem `iPhone 17`-Simulator bei Persistence-, Migration-, Backup- oder Import-Änderungen sowie auf `main` +6. fokussierter UI-Smoke auf einem `iPhone 17`-Simulator für `Home → Neuer Eintrag → Speichern` bei Home-, Capture- oder UI-Test-Änderungen sowie auf `main` +7. Upload der `xcresult`-Bundles nur bei Fehlern für nachvollziehbare Fehlerdiagnose in GitHub +8. Workflow `TestFlight Release` per manuellem GitHub-Actions-Start für Distribution-Signing via `match`, Build via `build_app` und Verteilung via `pilot` +9. optionaler `TestFlight Release`-Input `build_number`; leer bedeutet automatische fastlane-Buildnummer +10. Workflow `App Store Release` bei Git-Tags `vX.Y.Z` für Screenshot-Erstellung, Distribution-Signing via `match` und Upload via `deliver` +11. Die finale Einreichung erfolgt manuell in App Store Connect über `Submit` Lokale Vorab-Prüfung vor einem Tag-Release: -1. App im `Release`-Build in Xcode archivieren oder per `xcodebuild archive` bauen -2. Tests lokal gegen das Scheme `Symi` ausführen -3. offene Fehler in `GitHub Actions` oder `TestFlight` vor dem Tagging beseitigen +1. Unit-Tests auf Catalyst ausführen: + `xcodebuild test -project Symi.xcodeproj -scheme SymiTests -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst'` +2. Unit-Tests auf dem iPhone-Simulator ausführen: + `xcodebuild test -project Symi.xcodeproj -scheme SymiTests -destination 'platform=iOS Simulator,name=iPhone 17'` +3. UI-Smoke lokal ausführen: + `xcodebuild test -project Symi.xcodeproj -scheme SymiScreenshots -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:SymiUITests/HomeRedesignUITests/testHomeQuickEntryCanSaveHeadacheOnlyEntry` +4. Screenshot-Seed ohne vollständige Screenshot-Erzeugung validieren: + `bundle exec fastlane ios validate_screenshot_seed` +5. App im `Release`-Build in Xcode archivieren oder per `xcodebuild archive` bauen +6. offene Fehler in `GitHub Actions` oder `TestFlight` vor dem Tagging beseitigen ## Automatisierte Testabdeckung @@ -35,8 +45,11 @@ Die automatisierten Tests decken aktuell folgende Kernlogik ab: - Wetter-Snapshots für echte API-Daten und Zukunftsvalidierung - Export-Metriken für Durchschnittsintensität und Medikamentenliste - Insight Engine für Mindestdaten, Ausschlüsse, Durchschnitt, Trigger-/Wochentag-Thresholds, Trend und Hero-Sortierung +- SwiftData-Migration von V4 und V5 auf das aktuelle Schema inklusive Erhalt von Episoden, Medikation und Wetter-Kontext +- JSON5-Backup-Roundtrip inklusive Apple-Health-Kontext, Wetter-Snapshots und kontinuierlicher Medikation +- UI-Smoke für den Skala-zuerst-Erfassungsflow von Home bis Speichern -Damit sind die fehleranfälligen, nicht-visuellen Regeln des MVP reproduzierbar abgesichert, ohne das iPhone-UI unnötig kompliziert zu machen. +Damit sind die fehleranfälligen Regeln des MVP reproduzierbar abgesichert, während die schwere App-Store-Screenshot-Erzeugung getrennt vom schnellen PR-Gate bleibt. ## Manuelle Smoke-Tests auf dem iPhone-Simulator @@ -79,7 +92,7 @@ Vor einem Release-Kandidaten einmal vollständig prüfen: Ein Release-Kandidat ist freigabefähig, wenn: - der Workflow `iOS CI` auf `main` erfolgreich läuft -- der Workflow `TestFlight Release` auf `main` erfolgreich läuft +- der Workflow `TestFlight Release` für den gewünschten Build manuell erfolgreich läuft - die manuelle Checkliste ohne Blocker abgeschlossen ist - keine irreführenden medizinischen Aussagen oder Berechtigungstexte sichtbar sind @@ -89,6 +102,6 @@ Die Projektregeln für Releases sind: - `main` ist der einzige automatische Integrationspfad - Pull Requests und `main` werden über `GitHub Actions` validiert -- `TestFlight` wird über den Workflow `TestFlight Release` auf `main` verteilt +- `TestFlight` wird bewusst über den manuell gestarteten Workflow `TestFlight Release` verteilt - der `App Store` wird nur über Git-Tags im Format `vX.Y.Z` ausgelöst - `fastlane match`, `build_app`, `pilot` und `deliver` sind die Release-Werkzeuge für Distribution diff --git a/docs/Xcode-Cloud.md b/docs/Xcode-Cloud.md index 4a61347..779546b 100644 --- a/docs/Xcode-Cloud.md +++ b/docs/Xcode-Cloud.md @@ -42,6 +42,7 @@ Aus dem Projekt bestätigt: - `Release` verwendet `ICLOUD_CONTAINER_ENVIRONMENT = Production` Die Release-Lanes erzeugen in CI ein lokales Secrets-`xcconfig`, laden über `match` Distribution-Zertifikate und Provisioning Profiles in ein temporäres Keychain und bauen anschließend reproduzierbar mit manuellem Distribution-Signing. +Die GitHub-Workflows wählen für Swift-Builds explizit Xcode 26.4 aus. ## Zuständigkeiten @@ -49,13 +50,14 @@ Die Release-Lanes erzeugen in CI ein lokales Secrets-`xcconfig`, laden über `ma - `pull_request` auf `main` - `push` auf `main` -- Build des Shared Scheme `Symi` -- Ausführung von `SymiTests` -- Upload des `xcresult` als Artifact +- Xcode 26.4 für Swift-Builds, Tests und Release-Archive +- schnelles PR-Gate aus SwiftLint und Catalyst-Unit-Tests +- iPhone-Simulator-Tests und UI-Smoke bei relevanten Änderungen, auf `main` oder per manuellem Start +- Upload von `xcresult`-Bundles nur bei Fehlern Die Release-Workflows in `GitHub Actions` übernehmen zusätzlich die Distribution: -- `main` zu `TestFlight` +- manueller Start zu `TestFlight` - Git-Tags `vX.Y.Z` zum `App Store` - Distribution-Signing über `fastlane match` @@ -64,24 +66,26 @@ Die Release-Workflows in `GitHub Actions` übernehmen zusätzlich die Distributi Dieser Workflow liefert schnelles Entwickler-Feedback. - Name: `iOS CI` -- Startbedingung: `pull_request` auf `main` und `push` auf `main` +- Startbedingung: `pull_request` auf `main`, `push` auf `main` und manueller Start - Scheme: `Symi` - Aktionen: - - Build - - Tests - - Upload des `xcresult` + - SwiftLint/Design-Token-Regeln + - Unit-Tests auf `Mac Catalyst` + - Unit-Tests auf einem `iPhone 17`-Simulator bei Persistence-, Migration-, Backup- oder Import-Änderungen sowie auf `main` + - fokussierter UI-Smoke auf einem `iPhone 17`-Simulator bei Home-, Capture- oder UI-Test-Änderungen sowie auf `main` + - Upload der jeweiligen `xcresult`-Bundles nur bei Fehlern ## Workflow 2: TestFlight Release -Dieser Workflow ist für Beta-Verteilung auf Basis eines erfolgreichen `main`-Pushs zuständig. +Dieser Workflow ist für bewusst ausgelöste Beta-Verteilung zuständig. - Name: `TestFlight Release` -- Startbedingung: `push` auf `main` +- Startbedingung: manueller Start über `workflow_dispatch` - Scheme: `Symi` - Aktionen: - `setup_ci` für temporäres Keychain und `match`-Readonly-Modus - `match(type: "appstore")` - - nächste Buildnummer über `latest_testflight_build_number` und `GITHUB_RUN_ID` bestimmen + - optionale manuelle Buildnummer übernehmen oder automatisch bestimmen - `build_app(export_method: "app-store")` - Upload nach `TestFlight` mit `pilot` @@ -123,7 +127,7 @@ Das Repo enthält die Release-Logik jetzt vollständig in `fastlane/Fastfile`. 1. Änderungen nach `main` mergen 2. `GitHub Actions` führt `iOS CI` aus -3. `GitHub Actions` startet `TestFlight Release` +3. Workflow `TestFlight Release` in GitHub Actions manuell starten 4. `fastlane` synchronisiert Distribution-Signing mit `match` 5. `fastlane pilot` lädt die signierte IPA nach `TestFlight` @@ -151,7 +155,8 @@ git push origin v1.2.0 Die GitHub-Actions-Einrichtung gilt als korrekt, wenn: - `GitHub Actions` bei `pull_request` und `push` auf `main` erfolgreich läuft -- ein Push auf `main` den Workflow `TestFlight Release` startet -- der erfolgreiche `main`-Lauf einen `TestFlight`-Build erzeugt +- `iOS CI` das schnelle PR-Gate aus SwiftLint und Catalyst-Unit-Tests ausführt +- `iOS CI` iPhone- und UI-Gates bei relevanten Änderungen oder auf `main` ausführt +- ein manueller Start von `TestFlight Release` einen `TestFlight`-Build erzeugt - ein Tag wie `v1.2.0` ausschließlich den Workflow `App Store Release` startet - der Tag-Workflow Screenshots und ein veröffentlichbares Archiv in App Store Connect hochlädt, aber die Version nicht automatisch einreicht diff --git a/fastlane/Fastfile b/fastlane/Fastfile index b80f1a3..6e28488 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,6 +15,7 @@ LOCAL_SECRETS_PATH = File.join(REPO_ROOT, "Symi/Configs/LocalSecrets.xcconfig") SCREENSHOT_SCHEME = "SymiScreenshots" SCREENSHOT_TEST_TARGET = "SymiUITests" SCREENSHOT_TEST_IDENTIFIER = "SymiUITests/SymiScreenshotTests/testCaptureMainStoreScreens" +SCREENSHOT_SEED_TEST_IDENTIFIER = "SymiUITests/SymiScreenshotTests/testDefaultSeedShowsScaleFirstNewEntryFlow" SCREENSHOT_LANGUAGES = [ { language: "de-DE", locale: "de_DE" }, { language: "en-US", locale: "en_US" } @@ -30,7 +31,7 @@ MAC_SCREENSHOT_DEVICES = ["Mac"].freeze def screenshot_ios_version configured_version = ENV["SCREENSHOT_IOS_VERSION"].to_s.strip - configured_version.empty? ? "26.4" : configured_version + configured_version.empty? ? nil : configured_version end def include_mac_screenshots? @@ -68,7 +69,6 @@ def clear_screenshot_cache! end def resolve_simulator_for_capture(device_name, requested_version = screenshot_ios_version) - version_prefix = "#{requested_version}." candidates = SimCtl.list_devices.where(name: device_name).select(&:available?) candidates.sort_by! do |device| runtime_version = device.runtime.respond_to?(:version) ? device.runtime.version.to_s : "0" @@ -76,11 +76,15 @@ def resolve_simulator_for_capture(device_name, requested_version = screenshot_io end candidates.reverse! - exact_match = candidates.find do |device| - runtime_version = device.runtime.respond_to?(:version) ? device.runtime.version.to_s : "" - runtime_version == requested_version || runtime_version.start_with?(version_prefix) + unless requested_version.to_s.empty? + version_prefix = "#{requested_version}." + exact_match = candidates.find do |device| + runtime_version = device.runtime.respond_to?(:version) ? device.runtime.version.to_s : "" + runtime_version == requested_version || runtime_version.start_with?(version_prefix) + end + return exact_match if exact_match end - return exact_match if exact_match + return candidates.first if candidates.first UI.user_error!("Kein Simulator mit Namen '#{device_name}' für Screenshot-Läufe gefunden.") @@ -105,6 +109,15 @@ def clear_ios_status_bar!(device) end def run_screenshot_test!(destination:, result_bundle_path:, language:) + run_ui_test!( + destination: destination, + result_bundle_path: result_bundle_path, + language: language, + test_identifier: SCREENSHOT_TEST_IDENTIFIER + ) +end + +def run_ui_test!(destination:, result_bundle_path:, language:, test_identifier:) FileUtils.rm_rf(result_bundle_path) FileUtils.mkdir_p(File.dirname(result_bundle_path)) @@ -117,7 +130,7 @@ def run_screenshot_test!(destination:, result_bundle_path:, language:) "-derivedDataPath", derived_data_path.shellescape, "-resultBundlePath", result_bundle_path.shellescape, "-destination", destination.shellescape, - "-only-testing:#{SCREENSHOT_TEST_IDENTIFIER.shellescape}" + "-only-testing:#{test_identifier.shellescape}" ].join(" ") sh( @@ -163,7 +176,7 @@ def apply_snapshot_destination_patch! "iOS" end - requested_version = ::Snapshot.config[:ios_version] || ::Snapshot::LatestOsVersion.version(os) + requested_version = ::Snapshot.config[:ios_version] destinations = devices.map do |device_name| simulator = migraine_tracker_resolve_simulator(device_name, requested_version) "-destination 'platform=#{os} Simulator,id=#{simulator.udid}'" @@ -175,7 +188,6 @@ def apply_snapshot_destination_patch! private def migraine_tracker_resolve_simulator(device_name, requested_version) - version_prefix = "#{requested_version}." candidates = SimCtl.list_devices.where(name: device_name).select(&:available?) candidates.sort_by! do |device| runtime_version = device.runtime.respond_to?(:version) ? device.runtime.version.to_s : "0" @@ -183,11 +195,14 @@ def apply_snapshot_destination_patch! end candidates.reverse! - exact_match = candidates.find do |device| - runtime_version = device.runtime.respond_to?(:version) ? device.runtime.version.to_s : "" - runtime_version == requested_version || runtime_version.start_with?(version_prefix) + unless requested_version.to_s.empty? + version_prefix = "#{requested_version}." + exact_match = candidates.find do |device| + runtime_version = device.runtime.respond_to?(:version) ? device.runtime.version.to_s : "" + runtime_version == requested_version || runtime_version.start_with?(version_prefix) + end + return exact_match if exact_match end - return exact_match if exact_match fallback = candidates.first return fallback if fallback @@ -375,6 +390,24 @@ def capture_all_screenshots! end end +def validate_screenshot_seed! + language_config = SCREENSHOT_LANGUAGES.first + language = language_config.fetch(:language) + locale = language_config.fetch(:locale) + device_name = IOS_SCREENSHOT_DEVICES.first + device = resolve_simulator_for_capture(device_name) + + FileUtils.mkdir_p(SCREENSHOT_ROOT) + write_snapshot_configuration_files!(language: language, locale: locale) + prepare_ios_simulator!(device, override_status_bar: false) + run_ui_test!( + destination: "platform=iOS Simulator,id=#{device.udid}", + result_bundle_path: screenshot_result_bundle_path(File.join(SCREENSHOT_ROOT, "seed-check"), device_name, language: language), + language: language, + test_identifier: SCREENSHOT_SEED_TEST_IDENTIFIER + ) +end + def upload_screenshot_sets!(api_key:, version:) SCREENSHOT_LANGUAGES.each do |language_config| language = language_config.fetch(:language) @@ -433,6 +466,11 @@ platform :ios do capture_all_screenshots! end + desc "Validiert den Screenshot-Seed und den Skala-zuerst-Flow ohne vollständige Screenshot-Erzeugung" + lane :validate_screenshot_seed do + validate_screenshot_seed! + end + desc "Erzeugt und lädt iPhone-Screenshots nach App Store Connect hoch" lane :upload_screenshots do api_key = app_store_api_key diff --git a/fastlane/Snapfile b/fastlane/Snapfile index 4d04b16..17f956b 100644 --- a/fastlane/Snapfile +++ b/fastlane/Snapfile @@ -1,7 +1,7 @@ project("./Symi.xcodeproj") scheme("SymiScreenshots") test_target_name("SymiUITests") -ios_version(ENV.fetch("SCREENSHOT_IOS_VERSION", "26.4")) +ios_version(ENV["SCREENSHOT_IOS_VERSION"]) unless ENV["SCREENSHOT_IOS_VERSION"].to_s.empty? languages(["de-DE", "en-US"]) clear_previous_screenshots(true) skip_open_summary(true)