CI (Full) #93
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI (Full) | |
| # Unit + Integration suite, run against a live mock z2m bridge. Triggers: | |
| # 1. Nightly at 03:00 UTC (catches environmental / flaky issues) | |
| # 2. On-demand from the Actions tab | |
| # 3. Labeling a PR with `run-ui-tests` (kept for parity with the existing | |
| # flow even though the UI job itself was removed — see below) | |
| # | |
| # UI tests previously ran here too but were removed while the device list / | |
| # settings / logs UIs are in active redesign — the suite was reliably red on | |
| # tests-vs-UI mismatches that aren't real regressions, training us to ignore | |
| # the nightly badge. Restore the ui-tests job (git history) once the UI | |
| # settles and we have a green baseline to defend. | |
| # | |
| # Typical duration: ~10 min. Not required for PR merge — see ci-fast.yml | |
| # for the required check. | |
| on: | |
| schedule: | |
| # 03:00 UTC daily. Cron syntax: minute hour day month day-of-week. | |
| - cron: '0 3 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| reason: | |
| description: 'Why are you running the full suite?' | |
| required: false | |
| default: 'Manual run' | |
| pull_request: | |
| types: [labeled] | |
| concurrency: | |
| group: ci-full-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Guard: when triggered by `pull_request: labeled`, only run if the label | |
| # is exactly `run-ui-tests`. Without this, every label add would trigger. | |
| gate: | |
| name: Gate | |
| runs-on: ubuntu-latest | |
| if: github.event_name != 'pull_request' || github.event.label.name == 'run-ui-tests' | |
| steps: | |
| - name: Gate passed | |
| run: | | |
| echo "Trigger: ${{ github.event_name }}" | |
| unit-tests: | |
| name: Unit + Integration Tests | |
| needs: gate | |
| runs-on: macos-15 | |
| timeout-minutes: 25 | |
| env: | |
| SCHEME: Shellbee | |
| TEST_PLAN: Shellbee | |
| TEST_TARGET: ShellbeeTests | |
| DERIVED_DATA: ${{ github.workspace }}/DerivedData | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/DerivedData/SourcePackages/checkouts | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} | |
| restore-keys: | | |
| spm-${{ runner.os }}- | |
| - name: Start dual mock Z2M bridges (native) | |
| env: | |
| MULTI_BRIDGE: "1" | |
| run: ./.github/scripts/start-mock-bridge.sh | |
| - name: Select latest Xcode | |
| run: | | |
| LATEST=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -n1) | |
| sudo xcode-select -s "$LATEST" | |
| xcodebuild -version | |
| - name: Pick simulator | |
| id: sim | |
| run: | | |
| DEVICE_ID=$(xcrun simctl list devices available --json \ | |
| | jq -r '[.devices | to_entries[] | |
| | select(.key | test("iOS|com.apple.CoreSimulator.SimRuntime.iOS")) | |
| | .value[] | |
| | select(.isAvailable and (.name | startswith("iPhone")))] | |
| | sort_by(.name) | reverse | .[0].udid') | |
| NAME=$(xcrun simctl list devices --json \ | |
| | jq -r --arg id "$DEVICE_ID" '[.devices | to_entries[].value[] | select(.udid==$id)][0].name') | |
| echo "device_id=$DEVICE_ID" >> "$GITHUB_OUTPUT" | |
| echo "device_name=$NAME" >> "$GITHUB_OUTPUT" | |
| - name: Boot simulator and wait for readiness | |
| timeout-minutes: 4 | |
| run: | | |
| # `simctl bootstatus -b` boots (if not already) and BLOCKS until the | |
| # device is actually ready (springboard up, services responding) — | |
| # not just "Booted" state. The previous state-check loop returned | |
| # too early, leaving xcodebuild waiting on a not-yet-ready simulator | |
| # which silently hung for the full 30-minute test timeout. | |
| DEVICE_ID="${{ steps.sim.outputs.device_id }}" | |
| xcrun simctl bootstatus "$DEVICE_ID" -b | |
| - name: Resolve Swift packages | |
| run: | | |
| xcodebuild -resolvePackageDependencies \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -derivedDataPath "$DERIVED_DATA" | |
| - name: Build for testing | |
| env: | |
| DESTINATION: platform=iOS Simulator,id=${{ steps.sim.outputs.device_id }} | |
| run: | | |
| set -o pipefail | |
| xcodebuild build-for-testing \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -testPlan "$TEST_PLAN" \ | |
| -destination "$DESTINATION" -derivedDataPath "$DERIVED_DATA" \ | |
| CODE_SIGNING_ALLOWED=NO | tee build-unit.log | |
| - name: Run unit + integration tests | |
| timeout-minutes: 15 | |
| env: | |
| DESTINATION: platform=iOS Simulator,id=${{ steps.sim.outputs.device_id }} | |
| run: | | |
| # Skip the same tests that Shellbee-CI.xctestplan skips on Fast CI | |
| # (keychain/concurrency issues that only fail on GitHub runners) — | |
| # except Z2MIntegrationTests, which Full CI explicitly runs because | |
| # we now have the mock bridge up. See xctestplans/README.md. | |
| set -o pipefail | |
| xcodebuild test-without-building \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -testPlan "$TEST_PLAN" \ | |
| -destination "$DESTINATION" -derivedDataPath "$DERIVED_DATA" \ | |
| -only-testing:"$TEST_TARGET" \ | |
| -skip-testing:"ShellbeeTests/ConnectionConfigTests/testSaveAndLoad" \ | |
| -skip-testing:"ShellbeeTests/ConnectionHistoryTests" \ | |
| -skip-testing:"ShellbeeTests/HomeLayoutStoreTests" \ | |
| -skip-testing:"ShellbeeTests/NotificationPreferencesTests" \ | |
| -skip-testing:"ShellbeeTests/ConnectionConfigTests/testSecondLoadAfterLegacyMigrationStillReturnsToken" \ | |
| -skip-testing:"ShellbeeTests/Z2MIntegrationTests/testReloadedPersistedConfigConnectsAndReceivesBridgeInfo" \ | |
| -resultBundlePath "$DERIVED_DATA/UnitTests.xcresult" \ | |
| CODE_SIGNING_ALLOWED=NO | tee test-unit.log | |
| - name: Capture mock bridge logs | |
| if: always() | |
| run: | | |
| cp "$RUNNER_TEMP/z2m-bridge.log" mock-bridge.log 2>/dev/null || true | |
| cp "$RUNNER_TEMP/z2m-seeder.log" mock-seeder.log 2>/dev/null || true | |
| cp "$RUNNER_TEMP/z2m-bridge-2.log" mock-bridge-2.log 2>/dev/null || true | |
| cp "$RUNNER_TEMP/z2m-seeder-2.log" mock-seeder-2.log 2>/dev/null || true | |
| - name: Summarize | |
| if: always() | |
| env: | |
| DEVICE_NAME: ${{ steps.sim.outputs.device_name }} | |
| run: | | |
| { | |
| echo "## Unit Tests" | |
| echo | |
| echo "| Field | Value |" | |
| echo "| --- | --- |" | |
| echo "| Simulator | $DEVICE_NAME |" | |
| echo "| Target | $TEST_TARGET |" | |
| echo | |
| if [ -d "$DERIVED_DATA/UnitTests.xcresult" ]; then | |
| xcrun xcresulttool get --path "$DERIVED_DATA/UnitTests.xcresult" --format json 2>/dev/null \ | |
| | jq -r '.metrics | "**Tests: \(.testsCount._value // 0) • Failed: \(.testsFailedCount._value // 0) • Skipped: \(.testsSkippedCount._value // 0)**"' \ | |
| || echo "_Result bundle present but could not be parsed._" | |
| else | |
| echo "_No result bundle produced._" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload artifacts on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ci-full-unit-logs | |
| path: | | |
| build-unit.log | |
| test-unit.log | |
| mock-bridge.log | |
| mock-seeder.log | |
| mock-bridge-2.log | |
| mock-seeder-2.log | |
| ${{ env.DERIVED_DATA }}/UnitTests.xcresult | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| ipad-build: | |
| name: iPad build (build-only) | |
| needs: gate | |
| runs-on: macos-15 | |
| timeout-minutes: 15 | |
| env: | |
| SCHEME: Shellbee | |
| DERIVED_DATA: ${{ github.workspace }}/DerivedData | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/DerivedData/SourcePackages/checkouts | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} | |
| restore-keys: | | |
| spm-${{ runner.os }}- | |
| - name: Select latest Xcode | |
| run: | | |
| LATEST=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -n1) | |
| sudo xcode-select -s "$LATEST" | |
| xcodebuild -version | |
| - name: Pick iPad simulator | |
| id: sim | |
| run: | | |
| DEVICE_ID=$(xcrun simctl list devices available --json \ | |
| | jq -r '[.devices | to_entries[] | |
| | select(.key | test("iOS|com.apple.CoreSimulator.SimRuntime.iOS")) | |
| | .value[] | |
| | select(.isAvailable and (.name | startswith("iPad")))] | |
| | sort_by(.name) | reverse | .[0].udid') | |
| if [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "null" ]; then | |
| echo "No iPad simulator available on this runner" >&2 | |
| exit 1 | |
| fi | |
| NAME=$(xcrun simctl list devices --json \ | |
| | jq -r --arg id "$DEVICE_ID" '[.devices | to_entries[].value[] | select(.udid==$id)][0].name') | |
| echo "Using: $NAME ($DEVICE_ID)" | |
| echo "device_id=$DEVICE_ID" >> "$GITHUB_OUTPUT" | |
| echo "device_name=$NAME" >> "$GITHUB_OUTPUT" | |
| - name: Build for iPad | |
| env: | |
| DESTINATION: platform=iOS Simulator,id=${{ steps.sim.outputs.device_id }} | |
| run: | | |
| set -o pipefail | |
| xcodebuild build \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -destination "$DESTINATION" -derivedDataPath "$DERIVED_DATA" \ | |
| CODE_SIGNING_ALLOWED=NO | tee build-ipad.log | |
| - name: Upload artifacts on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ci-full-ipad-logs | |
| path: | | |
| build-ipad.log | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| ui-tests: | |
| name: UI Tests (smoke) | |
| # Only run when the PR is explicitly labeled `run-ui-tests`. The suite | |
| # was dormant during the multi-bridge redesign; running it on every | |
| # Full CI invocation hangs at 20 min with zero test execution while | |
| # the simulator + app launch story is sorted (#83). The job is kept | |
| # in the workflow so labeling a PR re-enables it once it's green. | |
| if: github.event_name == 'pull_request' && github.event.label.name == 'run-ui-tests' | |
| needs: gate | |
| runs-on: macos-15 | |
| timeout-minutes: 30 | |
| env: | |
| SCHEME: Shellbee | |
| TEST_PLAN: Shellbee | |
| TEST_TARGET: ShellbeeUITests | |
| DERIVED_DATA: ${{ github.workspace }}/DerivedData | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/DerivedData/SourcePackages/checkouts | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} | |
| restore-keys: | | |
| spm-${{ runner.os }}- | |
| - name: Start mock Z2M bridge (single) | |
| run: ./.github/scripts/start-mock-bridge.sh | |
| - name: Select latest Xcode | |
| run: | | |
| LATEST=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -n1) | |
| sudo xcode-select -s "$LATEST" | |
| xcodebuild -version | |
| - name: Pick simulator | |
| id: sim | |
| run: | | |
| DEVICE_ID=$(xcrun simctl list devices available --json \ | |
| | jq -r '[.devices | to_entries[] | |
| | select(.key | test("iOS|com.apple.CoreSimulator.SimRuntime.iOS")) | |
| | .value[] | |
| | select(.isAvailable and (.name | startswith("iPhone")))] | |
| | sort_by(.name) | reverse | .[0].udid') | |
| NAME=$(xcrun simctl list devices --json \ | |
| | jq -r --arg id "$DEVICE_ID" '[.devices | to_entries[].value[] | select(.udid==$id)][0].name') | |
| echo "device_id=$DEVICE_ID" >> "$GITHUB_OUTPUT" | |
| echo "device_name=$NAME" >> "$GITHUB_OUTPUT" | |
| - name: Boot simulator | |
| timeout-minutes: 4 | |
| run: xcrun simctl bootstatus "${{ steps.sim.outputs.device_id }}" -b | |
| - name: Resolve Swift packages | |
| run: | | |
| xcodebuild -resolvePackageDependencies \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -derivedDataPath "$DERIVED_DATA" | |
| - name: Build for testing | |
| env: | |
| DESTINATION: platform=iOS Simulator,id=${{ steps.sim.outputs.device_id }} | |
| run: | | |
| set -o pipefail | |
| xcodebuild build-for-testing \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -testPlan "$TEST_PLAN" \ | |
| -destination "$DESTINATION" -derivedDataPath "$DERIVED_DATA" \ | |
| CODE_SIGNING_ALLOWED=NO | tee build-ui.log | |
| - name: Run UI tests | |
| timeout-minutes: 20 | |
| env: | |
| DESTINATION: platform=iOS Simulator,id=${{ steps.sim.outputs.device_id }} | |
| run: | | |
| set -o pipefail | |
| xcodebuild test-without-building \ | |
| -project Shellbee.xcodeproj -scheme "$SCHEME" \ | |
| -testPlan "$TEST_PLAN" \ | |
| -destination "$DESTINATION" -derivedDataPath "$DERIVED_DATA" \ | |
| -only-testing:"$TEST_TARGET" \ | |
| -resultBundlePath "$DERIVED_DATA/UITests.xcresult" \ | |
| CODE_SIGNING_ALLOWED=NO | tee test-ui.log | |
| - name: Capture mock bridge logs | |
| if: always() | |
| run: | | |
| cp "$RUNNER_TEMP/z2m-bridge.log" mock-bridge.log 2>/dev/null || true | |
| cp "$RUNNER_TEMP/z2m-seeder.log" mock-seeder.log 2>/dev/null || true | |
| - name: Upload artifacts on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ci-full-ui-logs | |
| path: | | |
| build-ui.log | |
| test-ui.log | |
| mock-bridge.log | |
| mock-seeder.log | |
| ${{ env.DERIVED_DATA }}/UITests.xcresult | |
| if-no-files-found: ignore | |
| retention-days: 14 | |