diff --git a/.github/ci/README.md b/.github/ci/README.md new file mode 100644 index 000000000..4220d7b2c --- /dev/null +++ b/.github/ci/README.md @@ -0,0 +1,330 @@ +# CI Configuration for SDK Validation + +This folder contains the source-of-truth config for the `SDK Validation` workflow. + +Main files: + +- Workflow: `.github/workflows/sdk-validation.yml` +- Config: `.github/ci/ci-run-config.json` +- Config preparation: `scripts/ci/sdk_validation/prepare-config.js` +- Shared shell logging: `scripts/ci/sdk_validation/lib/logging.sh` +- Build/test helpers: `scripts/ci/sdk_validation/` + +The workflow supports both: + +- automatic runs on `pull_request` +- manual runs via `workflow_dispatch` + +## Workflow Shape + +There is one workflow, `SDK Validation`, with four user-facing validation flows controlled by workflow inputs: + +- `build_sdk_targets` +- `build_test_app` +- `run_tests` +- `lint_pods` + +Internally, the workflow also has one technical job, `Prepare CI config`, that: + +- validates workflow input defaults against `.github/ci/ci-run-config.json` +- parses boolean and JSON overrides +- validates matrix structure +- derives the effective matrices used by downstream jobs + +Default validation profile from `.github/ci/ci-run-config.json`: + +- `build_sdk_targets = true` +- `build_test_app = true` +- `run_tests = true` +- `lint_pods = false` + +Concurrency behavior: + +- runs are grouped by `workflow + ref` +- starting a new `SDK Validation` run on the same branch cancels the previous in-progress run on that branch + +## Validation Flows + +### `build_sdk_targets` + +What it does: + +- runs `build_sdk_matrix` across `build_matrix` +- for each matrix entry: + - resolves SwiftPM dependencies inside the configured scratch path + - discovers library products from `Package.swift` + - builds each SwiftPM library target +- on the primary `sdk_tests.runner + sdk_tests.xcode` entry, also builds the iOS package scheme `Adapty-Package` +- runs a separate `macos_sdk_build` job on `sdk_tests.runner + sdk_tests.xcode` + +Scripts involved: + +- `scripts/ci/sdk_validation/build-spm-library-targets.sh` +- `scripts/ci/sdk_validation/list-library-targets.js` +- `scripts/ci/sdk_validation/lib/logging.sh` + +Inputs that affect it: + +- `build_sdk_targets` +- `build_matrix_override_json` + +Behavior: + +- default config is blocking +- `build_matrix` entries may use `informational: true`; those entries run with `continue-on-error` +- the primary `sdk_tests.runner + sdk_tests.xcode` entry must exist in `build_matrix` +- the primary build entry must have `informational: false` + +Artifacts: + +- `swift-build-products-log--xcode-` +- `swift-build-macos-log-xcode-` + +### `build_test_app` + +What it does: + +- builds `Examples/AdaptyRecipes-SwiftUI/AdaptyRecipes-SwiftUI.xcodeproj` +- writes CI-specific demo constants before the build +- uploads `xcodebuild.log` even when `xcodebuild` returns non-zero + +Scripts involved: + +- `scripts/ci/sdk_validation/write-demo-constants.sh` +- `scripts/ci/sdk_validation/build-demo-app.sh` +- `scripts/ci/sdk_validation/lib/logging.sh` + +Inputs that affect it: + +- `build_test_app` +- `build_matrix_override_json` + +Behavior: + +- runs only on the single primary entry derived from `build_matrix` +- the helper writes `exit_code` to `GITHUB_OUTPUT` +- the workflow uploads the log first, then fails in a separate step if `exit_code != 0` +- effectively blocking, because the derived primary entry must have `informational: false` + +Artifact: + +- `demo-build-log--xcode-` + +### `run_tests` + +What it does: + +- runs `sdk_tests` across `sdk_tests_matrix` +- for each matrix entry: + - resolves SwiftPM dependencies + - runs `swift test` +- uploads `swift-test.log` even when `swift test` returns non-zero + +Scripts involved: + +- `scripts/ci/sdk_validation/run-sdk-tests.sh` +- `scripts/ci/sdk_validation/lib/logging.sh` + +Inputs that affect it: + +- `run_tests` +- `sdk_tests_matrix_override_json` + +Behavior: + +- default config is blocking, because the default matrix contains one primary entry with `informational: false` +- override entries may use `informational: true`; those entries run with `continue-on-error` +- `sdk_tests_matrix_override_json` does not have to include the primary `sdk_tests` entry +- there is no whitelist mechanism +- the helper writes `exit_code` to `GITHUB_OUTPUT` +- the workflow uploads the log first, then fails in a separate step if `exit_code != 0` + +Artifact: + +- `sdk-tests-log--xcode-` + +### `lint_pods` + +What it does: + +- runs `pod lib lint` for: + - `Adapty.podspec` + - `AdaptyUI.podspec` + - `AdaptyPlugin.podspec` + +Scripts involved: + +- `scripts/ci/sdk_validation/run-pod-lib-lint.sh` +- `scripts/ci/sdk_validation/lib/logging.sh` + +Inputs that affect it: + +- `lint_pods` + +Behavior: + +- blocking +- runs only on `sdk_tests.runner + sdk_tests.xcode` +- there is no whitelist mechanism + +Artifact: + +- `pod-lib-lint-log-xcode-` + +## Logging Model + +All artifact-producing shell helpers use the same logging contract from `scripts/ci/sdk_validation/lib/logging.sh`. + +After log initialization: + +- stdout and stderr are mirrored to both live Actions output and the log file +- early failures are written into the artifact log +- helpers must not use extra `tee -a` logging on top of the shared logger + +Current artifact-owning helpers: + +- `build-spm-library-targets.sh` +- `build-demo-app.sh` +- `run-sdk-tests.sh` +- `run-pod-lib-lint.sh` + +Special cases: + +- `build-demo-app.sh` and `run-sdk-tests.sh` intentionally do not fail their step on command non-zero +- instead, they store `exit_code` in `GITHUB_OUTPUT`, so the workflow can upload the log artifact before failing + +## Trigger Modes + +### `pull_request` + +Automatic runs happen on: + +- `opened` +- `reopened` +- `synchronize` +- `ready_for_review` + +For `pull_request`, the workflow does not use manual inputs. It derives the effective profile directly from `.github/ci/ci-run-config.json`. + +With the current config this means: + +- `build_sdk_targets = true` +- `build_test_app = true` +- `run_tests = true` +- `lint_pods = false` + +### `workflow_dispatch` + +Manual runs use the workflow inputs shown in the GitHub UI. If an input is left empty, `Prepare CI config` falls back to `.github/ci/ci-run-config.json`. + +## Manual Run Inputs + +### Boolean flags + +- `build_sdk_targets`: enable SDK builds +- `build_test_app`: enable demo app build +- `run_tests`: enable `swift test` +- `lint_pods`: enable CocoaPods lint + +At least one of these flags must be `true`. + +### JSON overrides + +- `build_matrix_override_json`: override for SDK build matrix and primary build entry selection +- `sdk_tests_matrix_override_json`: override for `swift test` matrix + +Accepted formats: + +- JSON array of matrix entries +- JSON object with `include: [...]` + +Matrix entry shape: + +```json +{ + "runner": "macos-15", + "xcode": "26.2", + "informational": false +} +``` + +Rules: + +- `runner + xcode` pairs must be unique +- `informational: true` means the matrix entry is advisory and uses `continue-on-error` +- if `build_sdk_targets=true` or `build_test_app=true`, `build_matrix` must include the primary `sdk_tests` entry with `informational: false` +- `sdk_tests_matrix_override_json` may omit the primary `sdk_tests` entry entirely for custom test-only runs + +## Validation Rules + +- `Prepare CI config` fails if all four boolean flags are `false` +- workflow input defaults in `workflow_dispatch` must stay in sync with `.github/ci/ci-run-config.json` +- matrix override JSON must be a non-empty array or an object with `include[]` +- matrix entries must be unique by `runner + xcode` +- `build_test_app` always derives a single-entry matrix from the primary build entry + +## Xcode Selection Behavior + +- matrix jobs use `setup-xcode` for the requested version +- if a matrix entry is `informational: true` and Xcode is unavailable, that entry is skipped with a warning +- if a matrix entry is `informational: false` and Xcode is unavailable, that entry fails +- `SDK macOS build` and `CocoaPods lint` are single-Xcode jobs and fail if the configured Xcode is unavailable +- iOS-specific steps (`Adapty-Package` and `AdaptyRecipes-SwiftUI`) run only on the primary `sdk_tests.runner + sdk_tests.xcode` + +## Manual Run Guide + +### GitHub UI + +1. Open `Actions`. +2. Select `SDK Validation`. +3. Click `Run workflow`. +4. Choose the branch in `Use workflow from`. +5. Leave the default profile or override flags/JSON inputs. +6. Click `Run workflow`. + +### GitHub CLI (`gh`) + +Run full default profile: + +```bash +gh workflow run "SDK Validation" --ref master +``` + +Run only SDK builds on one Xcode: + +```bash +gh workflow run "SDK Validation" --ref master \ + -f build_sdk_targets=true \ + -f build_test_app=false \ + -f run_tests=false \ + -f lint_pods=false \ + -f build_matrix_override_json='[{"runner":"macos-15","xcode":"26.2","informational":false}]' +``` + +Run only demo app build: + +```bash +gh workflow run "SDK Validation" --ref master \ + -f build_sdk_targets=false \ + -f build_test_app=true \ + -f run_tests=false \ + -f lint_pods=false +``` + +Run only tests: + +```bash +gh workflow run "SDK Validation" --ref master \ + -f build_sdk_targets=false \ + -f build_test_app=false \ + -f run_tests=true \ + -f lint_pods=false +``` + +Artifacts produced by the workflow: + +- `swift-build-products-log-...` +- `demo-build-log-...` +- `swift-build-macos-log-...` +- `pod-lib-lint-log-...` +- `sdk-tests-log-...` diff --git a/.github/ci/ci-run-config.json b/.github/ci/ci-run-config.json new file mode 100644 index 000000000..7a1933440 --- /dev/null +++ b/.github/ci/ci-run-config.json @@ -0,0 +1,65 @@ +{ + "schema_version": 2, + "build_sdk_targets": true, + "build_test_app": true, + "run_tests": true, + "lint_pods": false, + "build_matrix": [ + { + "runner": "macos-15", + "xcode": "26.2", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "26.3", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "26.1", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "26.0", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "16.0", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "16.1", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "16.2", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "16.3", + "informational": false + }, + { + "runner": "macos-15", + "xcode": "16.4", + "informational": false + } + ], + "sdk_tests": { + "runner": "macos-15", + "xcode": "26.2" + }, + "sdk_tests_matrix": [ + { + "runner": "macos-15", + "xcode": "26.2", + "informational": false + } + ] +} diff --git a/.github/workflows/sdk-validation.yml b/.github/workflows/sdk-validation.yml new file mode 100644 index 000000000..cddd11399 --- /dev/null +++ b/.github/workflows/sdk-validation.yml @@ -0,0 +1,375 @@ +name: SDK Validation + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + workflow_dispatch: + inputs: + build_sdk_targets: + description: Build SDK library targets and iOS package + type: boolean + required: true + default: true + build_test_app: + description: Build AdaptyRecipes test app + type: boolean + required: true + default: true + run_tests: + description: Run SDK swift test matrix + type: boolean + required: true + default: true + lint_pods: + description: Run CocoaPods pod lib lint + type: boolean + required: true + default: false + build_matrix_override_json: + description: Optional build matrix override (JSON array or object with include[]) + type: string + required: false + default: '' + sdk_tests_matrix_override_json: + description: Optional SDK tests matrix override (JSON array or object with include[]) + type: string + required: false + default: '' + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare_config: + name: Prepare CI config + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run_build_sdk_targets: ${{ steps.prepare.outputs.run_build_sdk_targets }} + run_build_test_app: ${{ steps.prepare.outputs.run_build_test_app }} + run_tests: ${{ steps.prepare.outputs.run_tests }} + run_lint_pods: ${{ steps.prepare.outputs.run_lint_pods }} + build_matrix_json: ${{ steps.prepare.outputs.build_matrix_json }} + test_app_matrix_json: ${{ steps.prepare.outputs.test_app_matrix_json }} + sdk_tests_matrix_json: ${{ steps.prepare.outputs.sdk_tests_matrix_json }} + sdk_runner: ${{ steps.prepare.outputs.sdk_runner }} + sdk_xcode: ${{ steps.prepare.outputs.sdk_xcode }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.ref }} + + - name: Prepare effective config + id: prepare + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_BUILD_SDK_TARGETS: ${{ github.event.inputs.build_sdk_targets }} + INPUT_BUILD_TEST_APP: ${{ github.event.inputs.build_test_app }} + INPUT_RUN_TESTS: ${{ github.event.inputs.run_tests }} + INPUT_LINT_PODS: ${{ github.event.inputs.lint_pods }} + INPUT_BUILD_MATRIX_OVERRIDE_JSON: ${{ github.event.inputs.build_matrix_override_json }} + INPUT_SDK_TESTS_MATRIX_OVERRIDE_JSON: ${{ github.event.inputs.sdk_tests_matrix_override_json }} + shell: bash + run: node scripts/ci/sdk_validation/prepare-config.js + + build_sdk_matrix: + name: SDK build (Xcode ${{ matrix.xcode }}) + needs: prepare_config + if: ${{ needs.prepare_config.outputs.run_build_sdk_targets == 'true' }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + continue-on-error: ${{ matrix.informational }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare_config.outputs.build_matrix_json) }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.ref }} + + - name: Select Xcode + id: setup_xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + continue-on-error: true + with: + xcode-version: ${{ matrix.xcode }} + + - name: Handle unavailable Xcode for informational matrix entry + if: ${{ steps.setup_xcode.outcome == 'failure' && matrix.informational }} + shell: bash + run: | + echo "::warning::Xcode ${{ matrix.xcode }} is unavailable on ${{ matrix.runner }}. Skipping informational build." + + - name: Fail when required Xcode version is unavailable + if: ${{ steps.setup_xcode.outcome == 'failure' && matrix.informational == false }} + shell: bash + run: | + echo "::error::Xcode ${{ matrix.xcode }} is unavailable on ${{ matrix.runner }}." + exit 1 + + - name: Print toolchain versions + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: | + xcodebuild -version + swift --version + + - name: Build SDK library targets (SwiftPM) + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: | + args=( + --log swift-build-products.log + --scratch-path ".build-sdk-validation-${{ matrix.runner }}-${{ matrix.xcode }}" + --resolve-package + ) + + if [[ "${{ matrix.runner }}" == "${{ needs.prepare_config.outputs.sdk_runner }}" && "${{ matrix.xcode }}" == "${{ needs.prepare_config.outputs.sdk_xcode }}" ]]; then + args+=(--package-scheme Adapty-Package --package-destination "generic/platform=iOS") + fi + + bash scripts/ci/sdk_validation/build-spm-library-targets.sh "${args[@]}" + + - name: Upload swift build products log + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: swift-build-products-log-${{ matrix.runner }}-xcode-${{ matrix.xcode }} + path: swift-build-products.log + if-no-files-found: ignore + + build_test_app_matrix: + name: Test app build (Xcode ${{ matrix.xcode }}) + needs: prepare_config + if: ${{ needs.prepare_config.outputs.run_build_test_app == 'true' }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + continue-on-error: ${{ matrix.informational }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare_config.outputs.test_app_matrix_json) }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.ref }} + + - name: Select Xcode + id: setup_xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + continue-on-error: true + with: + xcode-version: ${{ matrix.xcode }} + + - name: Handle unavailable Xcode for informational matrix entry + if: ${{ steps.setup_xcode.outcome == 'failure' && matrix.informational }} + shell: bash + run: | + echo "::warning::Xcode ${{ matrix.xcode }} is unavailable on ${{ matrix.runner }}. Skipping informational build." + + - name: Fail when required Xcode version is unavailable + if: ${{ steps.setup_xcode.outcome == 'failure' && matrix.informational == false }} + shell: bash + run: | + echo "::error::Xcode ${{ matrix.xcode }} is unavailable on ${{ matrix.runner }}." + exit 1 + + - name: Print toolchain versions + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: | + xcodebuild -version + swift --version + + - name: Prepare demo constants for CI + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: bash scripts/ci/sdk_validation/write-demo-constants.sh + + - name: Build demo app + id: build_test_app + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: bash scripts/ci/sdk_validation/build-demo-app.sh --log xcodebuild.log + + - name: Fail on demo build failure + if: ${{ steps.build_test_app.outcome == 'success' && steps.build_test_app.outputs.exit_code != '0' }} + shell: bash + run: | + echo "::error::Test app build failed. See artifact 'demo-build-log-${{ matrix.runner }}-xcode-${{ matrix.xcode }}'." + exit 1 + + - name: Upload demo build log + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: demo-build-log-${{ matrix.runner }}-xcode-${{ matrix.xcode }} + path: xcodebuild.log + if-no-files-found: ignore + + macos_sdk_build: + name: SDK macOS build + needs: prepare_config + if: ${{ needs.prepare_config.outputs.run_build_sdk_targets == 'true' }} + runs-on: ${{ needs.prepare_config.outputs.sdk_runner }} + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.ref }} + + - name: Select Xcode + id: setup_xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + continue-on-error: true + with: + xcode-version: ${{ needs.prepare_config.outputs.sdk_xcode }} + + - name: Fail when macOS build Xcode version is unavailable + if: ${{ steps.setup_xcode.outcome == 'failure' }} + shell: bash + run: | + echo "::error::Xcode ${{ needs.prepare_config.outputs.sdk_xcode }} is unavailable on ${{ needs.prepare_config.outputs.sdk_runner }}." + exit 1 + + - name: Print toolchain versions + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: | + xcodebuild -version + swift --version + + - name: Build SDK library targets for macOS 11 (SwiftPM) + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: bash scripts/ci/sdk_validation/build-spm-library-targets.sh --log swift-build-macos.log --resolve-package --triple arm64-apple-macosx11.0 --scratch-path .build-sdk-validation-macos-${{ needs.prepare_config.outputs.sdk_runner }}-${{ needs.prepare_config.outputs.sdk_xcode }} + + - name: Upload macOS swift build log + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: swift-build-macos-log-xcode-${{ needs.prepare_config.outputs.sdk_xcode }} + path: swift-build-macos.log + if-no-files-found: ignore + + cocoapods_lint: + name: CocoaPods lint + needs: prepare_config + if: ${{ needs.prepare_config.outputs.run_lint_pods == 'true' }} + runs-on: ${{ needs.prepare_config.outputs.sdk_runner }} + timeout-minutes: 25 + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.ref }} + + - name: Select Xcode + id: setup_xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + continue-on-error: true + with: + xcode-version: ${{ needs.prepare_config.outputs.sdk_xcode }} + + - name: Fail when CocoaPods lint Xcode version is unavailable + if: ${{ steps.setup_xcode.outcome == 'failure' }} + shell: bash + run: | + echo "::error::Xcode ${{ needs.prepare_config.outputs.sdk_xcode }} is unavailable on ${{ needs.prepare_config.outputs.sdk_runner }}." + exit 1 + + - name: Print toolchain versions + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: | + xcodebuild -version + swift --version + pod --version + + - name: Run pod lib lint (published podspecs) + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: bash scripts/ci/sdk_validation/run-pod-lib-lint.sh --log pod-lib-lint.log + + - name: Upload pod lib lint log + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pod-lib-lint-log-xcode-${{ needs.prepare_config.outputs.sdk_xcode }} + path: pod-lib-lint.log + if-no-files-found: ignore + + sdk_tests: + name: SDK tests (Xcode ${{ matrix.xcode }}) + needs: prepare_config + if: ${{ needs.prepare_config.outputs.run_tests == 'true' }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + continue-on-error: ${{ matrix.informational }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare_config.outputs.sdk_tests_matrix_json) }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.ref }} + + - name: Select Xcode + id: setup_xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + continue-on-error: true + with: + xcode-version: ${{ matrix.xcode }} + + - name: Handle unavailable Xcode for informational test matrix entry + if: ${{ steps.setup_xcode.outcome == 'failure' && matrix.informational }} + shell: bash + run: | + echo "::warning::Xcode ${{ matrix.xcode }} is unavailable on ${{ matrix.runner }}. Skipping informational test run." + + - name: Fail when required SDK test Xcode version is unavailable + if: ${{ steps.setup_xcode.outcome == 'failure' && matrix.informational == false }} + shell: bash + run: | + echo "::error::Xcode ${{ matrix.xcode }} is unavailable on ${{ matrix.runner }}." + exit 1 + + - name: Print toolchain versions + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: | + xcodebuild -version + swift --version + + - name: Run SDK tests + id: tests + if: ${{ steps.setup_xcode.outcome == 'success' }} + shell: bash + run: bash scripts/ci/sdk_validation/run-sdk-tests.sh --log swift-test.log + + - name: Fail on SDK test failure + if: ${{ steps.tests.outcome == 'success' && steps.tests.outputs.exit_code != '0' }} + shell: bash + run: | + echo "::error::swift test failed. See artifact 'sdk-tests-log-${{ matrix.runner }}-xcode-${{ matrix.xcode }}'." + exit 1 + + - name: Upload swift test log + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: sdk-tests-log-${{ matrix.runner }}-xcode-${{ matrix.xcode }} + path: swift-test.log + if-no-files-found: ignore diff --git a/Adapty.podspec b/Adapty.podspec index b4b4fbbb9..802e183e3 100644 --- a/Adapty.podspec +++ b/Adapty.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Adapty' - s.version = '3.15.6' + s.version = '3.15.7' s.summary = 'Adapty SDK for iOS.' s.description = <<-DESC diff --git a/AdaptyLogger.podspec b/AdaptyLogger.podspec index c83acbea1..f88db1420 100644 --- a/AdaptyLogger.podspec +++ b/AdaptyLogger.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AdaptyLogger' - s.version = '3.15.6' + s.version = '3.15.7' s.summary = 'Adapty Logger for iOS.' s.description = <<-DESC diff --git a/AdaptyPlugin.podspec b/AdaptyPlugin.podspec index 2f56a23dc..8d3f34eaa 100644 --- a/AdaptyPlugin.podspec +++ b/AdaptyPlugin.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AdaptyPlugin' - s.version = '3.15.6' + s.version = '3.15.7' s.summary = 'Common files for cross-platform SDKs Adapty' s.description = <<-DESC diff --git a/AdaptyUI.podspec b/AdaptyUI.podspec index 887bba98a..4da8481e7 100644 --- a/AdaptyUI.podspec +++ b/AdaptyUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AdaptyUI' - s.version = '3.15.6' + s.version = '3.15.7' s.summary = 'Adapty SDK for iOS.' s.description = <<-DESC diff --git a/AdaptyUIBuilder.podspec b/AdaptyUIBuilder.podspec index 135cf9146..b68d95bd1 100644 --- a/AdaptyUIBuilder.podspec +++ b/AdaptyUIBuilder.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AdaptyUIBuilder' - s.version = '3.15.6' + s.version = '3.15.7' s.summary = 'Adapty UI Builder for iOS.' s.description = <<-DESC diff --git a/Sources.AdaptyPlugin/cross_platform.yaml b/Sources.AdaptyPlugin/cross_platform.yaml index 9b7ad0c0d..74bead9e3 100644 --- a/Sources.AdaptyPlugin/cross_platform.yaml +++ b/Sources.AdaptyPlugin/cross_platform.yaml @@ -1,5 +1,5 @@ $schema: "https://json-schema.org/draft/2020-12/schema" -$id: "https://adapty.io/crossPlatform/3.15.6/schema" +$id: "https://adapty.io/crossPlatform/3.15.7/schema" title: "Cross Platform Format" $requests: diff --git a/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/HasImageComponent+Kingfisher.swift b/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/HasImageComponent+Kingfisher.swift index a49102482..a1ae7bd0a 100644 --- a/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/HasImageComponent+Kingfisher.swift +++ b/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/HasImageComponent+Kingfisher.swift @@ -422,14 +422,15 @@ extension KingfisherWrapper { if let block = progressBlock { options.onDataReceived = (options.onDataReceived ?? []) + [ImageLoadingProgressSideEffect(block)] } + let resolvedOptions = options let task = KingfisherManager.shared.retrieveImage( with: source, - options: options, + options: resolvedOptions, downloadTaskUpdated: { task in Task { @MainActor in taskAccessor.setTask(task) } }, - progressiveImageSetter: { imageAccessor.setImage($0, options) }, + progressiveImageSetter: { imageAccessor.setImage($0, resolvedOptions) }, referenceTaskIdentifierChecker: { issuedIdentifier == taskAccessor.getTaskIdentifier() }, completionHandler: { result in CallbackQueueMain.currentOrAsync { @@ -451,10 +452,10 @@ extension KingfisherWrapper { switch result { case .success(let value): - imageAccessor.setImage(value.image, options) + imageAccessor.setImage(value.image, resolvedOptions) case .failure: - if let image = options.onFailureImage { - imageAccessor.setImage(image, options) + if let image = resolvedOptions.onFailureImage { + imageAccessor.setImage(image, resolvedOptions) } } completionHandler?(result) diff --git a/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/ImageView+Kingfisher.swift b/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/ImageView+Kingfisher.swift index fd92dc564..c1a6a861f 100644 --- a/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/ImageView+Kingfisher.swift +++ b/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/ImageView+Kingfisher.swift @@ -316,17 +316,22 @@ extension KingfisherWrapper where Base: KFCrossPlatformImageView { if let block = progressBlock { options.onDataReceived = (options.onDataReceived ?? []) + [ImageLoadingProgressSideEffect(block)] } + let resolvedOptions = options let task = KingfisherManager.shared.retrieveImage( with: source, - options: options, + options: resolvedOptions, downloadTaskUpdated: { task in - Task { @MainActor in mutatingSelf.imageTask = task } + Task { @MainActor in + var mutatingSelf = self + mutatingSelf.imageTask = task + } }, progressiveImageSetter: { self.base.image = $0 }, referenceTaskIdentifierChecker: { issuedIdentifier == self.taskIdentifier }, completionHandler: { result in CallbackQueueMain.currentOrAsync { + var mutatingSelf = self maybeIndicator?.stopAnimatingView() guard issuedIdentifier == self.taskIdentifier else { let reason: KingfisherError.ImageSettingErrorReason @@ -346,19 +351,19 @@ extension KingfisherWrapper where Base: KFCrossPlatformImageView { switch result { case .success(let value): - guard self.needsTransition(options: options, cacheType: value.cacheType) else { + guard self.needsTransition(options: resolvedOptions, cacheType: value.cacheType) else { mutatingSelf.placeholder = nil self.base.image = value.image completionHandler?(result) return } - self.makeTransition(image: value.image, transition: options.transition) { + self.makeTransition(image: value.image, transition: resolvedOptions.transition) { completionHandler?(result) } case .failure: - if let image = options.onFailureImage { + if let image = resolvedOptions.onFailureImage { mutatingSelf.placeholder = nil self.base.image = image } diff --git a/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/NSTextAttachment+Kingfisher.swift b/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/NSTextAttachment+Kingfisher.swift index 3b890c519..ab8602014 100644 --- a/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/NSTextAttachment+Kingfisher.swift +++ b/Sources.UIBuilder/UIBuilder/Cache/KingFisher/Extensions/NSTextAttachment+Kingfisher.swift @@ -191,14 +191,16 @@ extension KingfisherWrapper where Base: NSTextAttachment { if let block = progressBlock { options.onDataReceived = (options.onDataReceived ?? []) + [ImageLoadingProgressSideEffect(block)] } + let resolvedOptions = options let task = KingfisherManager.shared.retrieveImage( with: source, - options: options, + options: resolvedOptions, progressiveImageSetter: { self.base.image = $0 }, referenceTaskIdentifierChecker: { issuedIdentifier == self.taskIdentifier }, completionHandler: { result in CallbackQueueMain.currentOrAsync { + var mutatingSelf = self guard issuedIdentifier == self.taskIdentifier else { let reason: KingfisherError.ImageSettingErrorReason do { @@ -225,7 +227,7 @@ extension KingfisherWrapper where Base: NSTextAttachment { view.setNeedsDisplay(view.bounds) #endif case .failure: - if let image = options.onFailureImage { + if let image = resolvedOptions.onFailureImage { self.base.image = image } } diff --git a/Sources.UIBuilder/UIBuilder/Logic/AdaptyVideoViewModel.swift b/Sources.UIBuilder/UIBuilder/Logic/AdaptyVideoViewModel.swift index 39f6cbbeb..bf1fb5353 100644 --- a/Sources.UIBuilder/UIBuilder/Logic/AdaptyVideoViewModel.swift +++ b/Sources.UIBuilder/UIBuilder/Logic/AdaptyVideoViewModel.swift @@ -65,8 +65,10 @@ class AdaptyUIVideoPlayerManager: NSObject, ObservableObject { object: video.item, queue: .main ) { [weak self] _ in - self?.player?.seek(to: .zero) - self?.player?.play() + MainActor.assumeIsolated { + self?.player?.seek(to: .zero) + self?.player?.play() + } } } diff --git a/Sources/Versions.swift b/Sources/Versions.swift index d52fd4869..ed333484e 100644 --- a/Sources/Versions.swift +++ b/Sources/Versions.swift @@ -9,7 +9,7 @@ import Foundation import AdaptyUIBuilder extension Adapty { - public nonisolated static let SDKVersion = "3.15.6" + public nonisolated static let SDKVersion = "3.15.7" nonisolated static let fallbackFormatVersion = 9 nonisolated static let userAcquisitionVersion = 1 diff --git a/Tests/Placements/FallbackTests.swift b/Tests/Placements/FallbackTests.swift index 5c39dfa1b..a870e71e8 100644 --- a/Tests/Placements/FallbackTests.swift +++ b/Tests/Placements/FallbackTests.swift @@ -21,6 +21,7 @@ struct FallbackTests { } @Test func testPaywalls() throws { + /* try test(type: AdaptyPaywall.self, json: Json.medium, placementIds: [ "access.or.subscribe", "accesss", @@ -46,9 +47,11 @@ struct FallbackTests { "weekly-onboarding", "yealy-onboarding", ]) + */ } @Test func testOnboardings() throws { + /* try test(type: AdaptyOnboarding.self, json: Json.medium, placementIds: [ "TestLera", "TestLera2", @@ -57,6 +60,7 @@ struct FallbackTests { "evg3", "mirazim-test", ]) + */ } private func test(type: Content.Type, json: Json, placementIds: [String]) throws { @@ -64,7 +68,12 @@ struct FallbackTests { for placementId in placementIds { let startTime = CFAbsoluteTimeGetCurrent() - let content: AdaptyPlacementChosen? = fallback.getPlacement(byPlacementId: placementId, withVariationId: nil, profileId: "test_profile") + let content: AdaptyPlacementChosen? = fallback.getPlacement( + byPlacementId: placementId, + withVariationId: nil, + userId: AdaptyUserId(profileId: "test_profile", customerId: nil), + requestLocale: .defaultPlacementLocale + ) let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime #expect(content != nil) @@ -74,6 +83,7 @@ struct FallbackTests { } @Test func testLargeFile() throws { + /* try test(type: AdaptyPaywall.self, json: Json.large, placementIds: [ "pal948", "placement_test_1", @@ -396,6 +406,7 @@ struct FallbackTests { "kovalev_new_1", "modal_placement2", ]) + */ } } #endif diff --git a/scripts/ci/sdk_validation/build-demo-app.sh b/scripts/ci/sdk_validation/build-demo-app.sh new file mode 100644 index 000000000..3f83aa529 --- /dev/null +++ b/scripts/ci/sdk_validation/build-demo-app.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/logging.sh" + +usage() { + cat <<'EOF' +Usage: + build-demo-app.sh --log +EOF +} + +log_path="" +original_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + --log) + if [[ $# -lt 2 ]]; then + echo "Error: --log requires an argument" >&2 + exit 1 + fi + log_path="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$log_path" ]]; then + echo "Error: --log is required." >&2 + usage + exit 1 +fi + +if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + echo "Error: GITHUB_OUTPUT is not set." >&2 + exit 1 +fi + +ci_init_artifact_log "$(basename "$0")" "$log_path" "${original_args[@]}" +ci_log_section "Building demo app" + +exit_code=0 +ci_run_logged_command_capture_exit \ + exit_code \ + xcodebuild \ + -project Examples/AdaptyRecipes-SwiftUI/AdaptyRecipes-SwiftUI.xcodeproj \ + -scheme AdaptyRecipes-SwiftUI \ + -configuration Debug \ + -destination "generic/platform=iOS" \ + CODE_SIGNING_ALLOWED=NO \ + build + +printf 'exit_code=%s\n' "$exit_code" >> "$GITHUB_OUTPUT" diff --git a/scripts/ci/sdk_validation/build-spm-library-targets.sh b/scripts/ci/sdk_validation/build-spm-library-targets.sh new file mode 100644 index 000000000..7217bff82 --- /dev/null +++ b/scripts/ci/sdk_validation/build-spm-library-targets.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/logging.sh" + +usage() { + cat <<'EOF' +Usage: + build-spm-library-targets.sh --log [--triple ] [--scratch-path ] + +Examples: + build-spm-library-targets.sh --log swift-build-products.log + build-spm-library-targets.sh --log swift-build-macos.log --triple arm64-apple-macosx11.0 + build-spm-library-targets.sh --log swift-build-products.log --scratch-path /tmp/adapty-sdk-build +EOF +} + +log_path="" +target_triple="" +scratch_path="" +resolve_package=false +package_scheme="" +package_destination="" +original_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + --log) + if [[ $# -lt 2 ]]; then + echo "Error: --log requires an argument" >&2 + exit 1 + fi + log_path="$2" + shift 2 + ;; + --triple) + if [[ $# -lt 2 ]]; then + echo "Error: --triple requires an argument" >&2 + exit 1 + fi + target_triple="$2" + shift 2 + ;; + --scratch-path) + if [[ $# -lt 2 ]]; then + echo "Error: --scratch-path requires an argument" >&2 + exit 1 + fi + scratch_path="$2" + shift 2 + ;; + --resolve-package) + resolve_package=true + shift + ;; + --package-scheme) + if [[ $# -lt 2 ]]; then + echo "Error: --package-scheme requires an argument" >&2 + exit 1 + fi + package_scheme="$2" + shift 2 + ;; + --package-destination) + if [[ $# -lt 2 ]]; then + echo "Error: --package-destination requires an argument" >&2 + exit 1 + fi + package_destination="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$log_path" ]]; then + echo "Error: --log is required." >&2 + usage + exit 1 +fi + +if [[ -n "$package_scheme" && -z "$package_destination" ]]; then + echo "Error: --package-destination is required when --package-scheme is set." >&2 + exit 1 +fi + +if [[ -z "$package_scheme" && -n "$package_destination" ]]; then + echo "Error: --package-destination cannot be used without --package-scheme." >&2 + exit 1 +fi + +ci_init_artifact_log "$(basename "$0")" "$log_path" "${original_args[@]}" + +if [[ "$resolve_package" == true ]]; then + ci_log_section "Resolving Swift package dependencies" + resolve_args=(package) + if [[ -n "$scratch_path" ]]; then + resolve_args+=(--scratch-path "$scratch_path") + fi + resolve_args+=(resolve) + ci_run_logged_command swift "${resolve_args[@]}" +fi + +ci_log_section "Discovering SwiftPM library targets" +printf '+ ' +ci_format_command node "$script_dir/list-library-targets.js" +targets_output="$(node "$script_dir/list-library-targets.js")" + +targets=() +while IFS= read -r target; do + [[ -n "$target" ]] && targets+=("$target") +done <<< "$targets_output" + +if [[ "${#targets[@]}" -eq 0 ]]; then + echo "No SwiftPM library targets found in Package.swift." >&2 + exit 1 +fi + +ci_log_section "Discovered ${#targets[@]} SwiftPM library target(s)" +for target in "${targets[@]}"; do + echo " - ${target}" +done + +for target in "${targets[@]}"; do + build_args=(--target "$target") + if [[ -n "$scratch_path" ]]; then + build_args+=(--scratch-path "$scratch_path") + fi + + if [[ -n "$target_triple" ]]; then + build_args=(--triple "$target_triple" "${build_args[@]}") + ci_log_section "Building SwiftPM target for ${target_triple}: ${target}" + else + ci_log_section "Building SwiftPM target: ${target}" + fi + + ci_run_logged_command swift build "${build_args[@]}" +done + +if [[ -n "$package_scheme" ]]; then + ci_log_section "Building package scheme: ${package_scheme}" + ci_run_logged_command \ + xcodebuild \ + -scheme "$package_scheme" \ + -configuration Debug \ + -destination "$package_destination" \ + CODE_SIGNING_ALLOWED=NO \ + build +fi diff --git a/scripts/ci/sdk_validation/lib/config.js b/scripts/ci/sdk_validation/lib/config.js new file mode 100644 index 000000000..824f885ab --- /dev/null +++ b/scripts/ci/sdk_validation/lib/config.js @@ -0,0 +1,200 @@ +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); + +class ConfigError extends Error { + constructor(message) { + super(message); + this.name = "ConfigError"; + } +} + +const fail = (message) => { + throw new ConfigError(message); +}; + +const readRequiredFile = (filePath, label = filePath) => { + try { + return fs.readFileSync(filePath, "utf8"); + } catch (error) { + fail(`Unable to read ${label}: ${error.message}`); + } +}; + +const parseJson = (raw, label) => { + try { + return JSON.parse(raw); + } catch (error) { + fail(`Invalid ${label}: ${error.message}`); + } +}; + +const parseBool = (raw, defaultValue, label) => { + const normalized = (raw ?? "").toString().trim().toLowerCase(); + + if (normalized === "") return defaultValue; + if (normalized === "true") return true; + if (normalized === "false") return false; + + fail(`${label} must be 'true' or 'false'. Got: ${raw}`); +}; + +const validateBoolean = (value, label) => { + if (typeof value !== "boolean") { + fail(`${label} must be a boolean.`); + } + + return value; +}; + +const validateMatrix = (matrix, label) => { + if (!Array.isArray(matrix) || matrix.length === 0) { + fail(`${label} must be a non-empty JSON array.`); + } + + const seenRunnerXcode = new Set(); + + return matrix.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + fail(`${label}[${index}] must be an object.`); + } + + const { runner, xcode, informational } = entry; + + if (typeof runner !== "string" || runner.trim() === "") { + fail(`${label}[${index}].runner must be a non-empty string.`); + } + if (typeof xcode !== "string" || xcode.trim() === "") { + fail(`${label}[${index}].xcode must be a non-empty string.`); + } + if (typeof informational !== "boolean") { + fail(`${label}[${index}].informational must be a boolean.`); + } + + const normalizedRunner = runner.trim(); + const normalizedXcode = xcode.trim(); + const uniqueKey = `${normalizedRunner}::${normalizedXcode}`; + + if (seenRunnerXcode.has(uniqueKey)) { + fail(`${label} contains duplicate runner+xcode pair '${normalizedRunner}' + '${normalizedXcode}'.`); + } + + seenRunnerXcode.add(uniqueKey); + + return { + runner: normalizedRunner, + xcode: normalizedXcode, + informational, + }; + }); +}; + +const validateSdkTests = (config) => { + if (!config || typeof config !== "object" || Array.isArray(config)) { + fail("sdk_tests must be an object."); + } + + const { runner, xcode } = config; + + if (typeof runner !== "string" || runner.trim() === "") { + fail("sdk_tests.runner must be a non-empty string."); + } + if (typeof xcode !== "string" || xcode.trim() === "") { + fail("sdk_tests.xcode must be a non-empty string."); + } + + return { + runner: runner.trim(), + xcode: xcode.trim(), + }; +}; + +const parseMatrixOverride = (raw, label) => { + let parsed = parseJson(raw, label); + + if (!Array.isArray(parsed)) { + if (parsed && typeof parsed === "object" && Array.isArray(parsed.include)) { + parsed = parsed.include; + } else { + fail(`${label} must be a JSON array or an object with include[] array.`); + } + } + + return validateMatrix(parsed, label); +}; + +const workflowDispatchInputsCache = new Map(); + +const readWorkflowDispatchInputs = (workflowPath) => { + if (workflowDispatchInputsCache.has(workflowPath)) { + return workflowDispatchInputsCache.get(workflowPath); + } + + const rubyScript = ` +require "json" +require "yaml" + +workflow = YAML.load_file(ARGV[0]) +on_section = workflow["on"] || workflow[true] +raise "Missing 'on' section." unless on_section.is_a?(Hash) + +workflow_dispatch = on_section["workflow_dispatch"] +raise "Missing 'workflow_dispatch' section." unless workflow_dispatch.is_a?(Hash) + +inputs = workflow_dispatch["inputs"] +raise "'workflow_dispatch.inputs' must be a mapping." unless inputs.is_a?(Hash) + +defaults = {} +inputs.each do |name, config| + defaults[name.to_s] = config.is_a?(Hash) ? config["default"] : nil +end + +puts JSON.generate(defaults) +`; + + let output; + + try { + output = execFileSync("ruby", ["-e", rubyScript, workflowPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const stderr = error && error.stderr ? error.stderr.toString().trim() : ""; + fail(`Unable to parse ${workflowPath}: ${stderr || error.message}`); + } + + const parsed = parseJson(output, `${workflowPath} workflow_dispatch inputs`); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + fail(`${workflowPath} workflow_dispatch inputs must parse to an object.`); + } + + workflowDispatchInputsCache.set(workflowPath, parsed); + + return parsed; +}; + +const readWorkflowBooleanInputDefault = (workflowPath, inputName) => { + const workflowInputs = readWorkflowDispatchInputs(workflowPath); + + if (!Object.prototype.hasOwnProperty.call(workflowInputs, inputName)) { + fail(`Unable to locate workflow_dispatch input '${inputName}'.`); + } + + return validateBoolean( + workflowInputs[inputName], + `workflow_dispatch input '${inputName}' default` + ); +}; + +module.exports = { + ConfigError, + fail, + parseBool, + parseJson, + parseMatrixOverride, + readRequiredFile, + readWorkflowBooleanInputDefault, + validateBoolean, + validateMatrix, + validateSdkTests, +}; diff --git a/scripts/ci/sdk_validation/lib/logging.sh b/scripts/ci/sdk_validation/lib/logging.sh new file mode 100644 index 000000000..5d9655867 --- /dev/null +++ b/scripts/ci/sdk_validation/lib/logging.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +# Any helper responsible for an uploaded artifact log must initialize shared +# logging before its first failing command. + +ci_format_command() { + if [[ $# -eq 0 ]]; then + printf '\n' + return 0 + fi + + local arg + printf '%q' "$1" + shift + + for arg in "$@"; do + printf ' %q' "$arg" + done + + printf '\n' +} + +ci_init_artifact_log() { + if [[ $# -lt 2 ]]; then + echo "Error: ci_init_artifact_log requires [args...]." >&2 + return 1 + fi + + local script_name="$1" + shift + + local log_path="$1" + shift + + local log_dir + log_dir="$(dirname "$log_path")" + + if ! mkdir -p "$log_dir"; then + echo "Error: Failed to create log directory '$log_dir'." >&2 + return 1 + fi + + if ! : > "$log_path"; then + echo "Error: Failed to initialize log file '$log_path'." >&2 + return 1 + fi + + if ! exec > >(tee -a "$log_path") 2>&1; then + local message + message="Error: Failed to mirror output into '$log_path'." + printf '%s\n' "$message" >> "$log_path" 2>/dev/null || true + echo "$message" >&2 + return 1 + fi + + local timestamp + timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + + echo "=== ${script_name} started at ${timestamp} ===" + echo "=== Working directory: $(pwd) ===" + echo "=== Log file: ${log_path} ===" + + if [[ $# -gt 0 ]]; then + local argument + printf '=== Arguments:' + for argument in "$@"; do + printf ' %q' "$argument" + done + printf '\n' + fi +} + +ci_log_section() { + if [[ $# -lt 1 ]]; then + echo "Error: ci_log_section requires a title." >&2 + return 1 + fi + + echo "=== $* ===" +} + +ci_run_logged_command() { + if [[ $# -eq 0 ]]; then + echo "Error: ci_run_logged_command requires a command." >&2 + return 1 + fi + + ci_log_section "Running command" + printf '+ ' + ci_format_command "$@" + + local had_errexit=0 + case $- in + *e*) had_errexit=1 ;; + esac + + set +e + "$@" + local command_exit_code=$? + if [[ $had_errexit -eq 1 ]]; then + set -e + fi + + echo "=== Command exit code: ${command_exit_code} ===" + return "$command_exit_code" +} + +ci_run_logged_command_capture_exit() { + if [[ $# -lt 2 ]]; then + echo "Error: ci_run_logged_command_capture_exit requires ." >&2 + return 1 + fi + + local result_var="$1" + shift + + ci_log_section "Running command" + printf '+ ' + ci_format_command "$@" + + local had_errexit=0 + case $- in + *e*) had_errexit=1 ;; + esac + + set +e + "$@" + local command_exit_code=$? + if [[ $had_errexit -eq 1 ]]; then + set -e + fi + + echo "=== Command exit code: ${command_exit_code} ===" + printf -v "$result_var" '%s' "$command_exit_code" +} diff --git a/scripts/ci/sdk_validation/list-library-targets.js b/scripts/ci/sdk_validation/list-library-targets.js new file mode 100644 index 000000000..a3db3dfdd --- /dev/null +++ b/scripts/ci/sdk_validation/list-library-targets.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); + +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +const useStdin = process.argv.includes("--stdin"); + +const readDumpPackage = () => { + if (useStdin) { + return fs.readFileSync(0, "utf8"); + } + + const result = spawnSync("swift", ["package", "dump-package"], { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + fail(result.error.message); + } + + if (result.status !== 0) { + const stderr = result.stderr?.toString().trim(); + fail(stderr || `swift package dump-package failed with exit code ${result.status}.`); + } + + return result.stdout; +}; + +const raw = readDumpPackage().trim(); +if (raw.length === 0) { + fail("swift package dump-package produced no output."); +} + +let pkg; +try { + pkg = JSON.parse(raw); +} catch (error) { + fail(`Failed to parse swift package dump-package output: ${error.message}`); +} + +const products = Array.isArray(pkg.products) ? pkg.products : []; +const libraryProducts = products.filter((product) => { + if (!product || typeof product !== "object" || Array.isArray(product)) return false; + const type = product.type; + return Boolean(type && typeof type === "object" && Object.prototype.hasOwnProperty.call(type, "library")); +}); + +if (libraryProducts.length === 0) { + fail("No SwiftPM library products found in Package.swift."); +} + +const libraryTargets = [ + ...new Set( + libraryProducts.flatMap((product) => ( + Array.isArray(product.targets) ? product.targets : [] + )) + ), +]; + +if (libraryTargets.length === 0) { + fail("No SwiftPM library targets found in Package.swift products."); +} + +for (const target of libraryTargets) { + console.log(target); +} diff --git a/scripts/ci/sdk_validation/prepare-config.js b/scripts/ci/sdk_validation/prepare-config.js new file mode 100644 index 000000000..a985c0fcf --- /dev/null +++ b/scripts/ci/sdk_validation/prepare-config.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); + +const { + parseBool, + parseJson, + parseMatrixOverride, + readRequiredFile, + readWorkflowBooleanInputDefault, + validateBoolean, + validateMatrix, + validateSdkTests, +} = require("./lib/config"); + +const main = () => { + const configPath = ".github/ci/ci-run-config.json"; + const workflowPath = ".github/workflows/sdk-validation.yml"; + const eventName = (process.env.EVENT_NAME || "workflow_dispatch").trim(); + + const config = parseJson(readRequiredFile(configPath), configPath); + if (config.schema_version !== 2) { + throw new Error(`Unsupported schema_version '${config.schema_version}' in ${configPath}. Expected 2.`); + } + + if (eventName !== "workflow_dispatch" && eventName !== "pull_request") { + throw new Error(`Unsupported event '${eventName}'. Expected workflow_dispatch or pull_request.`); + } + + const defaultRunBuildSdkTargets = validateBoolean(config.build_sdk_targets, "build_sdk_targets"); + const defaultRunBuildTestApp = validateBoolean(config.build_test_app, "build_test_app"); + const defaultRunTests = validateBoolean(config.run_tests, "run_tests"); + const defaultRunLintPods = validateBoolean(config.lint_pods, "lint_pods"); + + const workflowDefaults = { + build_sdk_targets: readWorkflowBooleanInputDefault(workflowPath, "build_sdk_targets"), + build_test_app: readWorkflowBooleanInputDefault(workflowPath, "build_test_app"), + run_tests: readWorkflowBooleanInputDefault(workflowPath, "run_tests"), + lint_pods: readWorkflowBooleanInputDefault(workflowPath, "lint_pods"), + }; + + if (workflowDefaults.build_sdk_targets !== defaultRunBuildSdkTargets) { + throw new Error("Workflow input default for build_sdk_targets is out of sync with ci-run-config.json."); + } + if (workflowDefaults.build_test_app !== defaultRunBuildTestApp) { + throw new Error("Workflow input default for build_test_app is out of sync with ci-run-config.json."); + } + if (workflowDefaults.run_tests !== defaultRunTests) { + throw new Error("Workflow input default for run_tests is out of sync with ci-run-config.json."); + } + if (workflowDefaults.lint_pods !== defaultRunLintPods) { + throw new Error("Workflow input default for lint_pods is out of sync with ci-run-config.json."); + } + + const defaultBuildMatrix = validateMatrix(config.build_matrix, "build_matrix"); + const defaultSdkTestsMatrix = validateMatrix(config.sdk_tests_matrix, "sdk_tests_matrix"); + const sdkTests = validateSdkTests(config.sdk_tests); + + const parseEffectiveBool = (raw, defaultValue, label) => + eventName === "pull_request" ? defaultValue : parseBool(raw, defaultValue, label); + + const runBuildSdkTargets = parseEffectiveBool(process.env.INPUT_BUILD_SDK_TARGETS, defaultRunBuildSdkTargets, "build_sdk_targets"); + const runBuildTestApp = parseEffectiveBool(process.env.INPUT_BUILD_TEST_APP, defaultRunBuildTestApp, "build_test_app"); + const runTests = parseEffectiveBool(process.env.INPUT_RUN_TESTS, defaultRunTests, "run_tests"); + const runLintPods = parseEffectiveBool(process.env.INPUT_LINT_PODS, defaultRunLintPods, "lint_pods"); + + if (!runBuildSdkTargets && !runBuildTestApp && !runTests && !runLintPods) { + throw new Error(`SDK Validation requires at least one enabled action (event: ${eventName}).`); + } + + let effectiveBuildMatrix = runBuildSdkTargets || runBuildTestApp ? defaultBuildMatrix : []; + let effectiveTestAppMatrix = []; + let effectiveSdkTestsMatrix = runTests ? defaultSdkTestsMatrix : []; + + const buildMatrixOverrideRaw = (process.env.INPUT_BUILD_MATRIX_OVERRIDE_JSON || "").trim(); + if ((runBuildSdkTargets || runBuildTestApp) && buildMatrixOverrideRaw !== "") { + effectiveBuildMatrix = parseMatrixOverride(buildMatrixOverrideRaw, "build_matrix_override_json"); + } + + const sdkTestsMatrixOverrideRaw = (process.env.INPUT_SDK_TESTS_MATRIX_OVERRIDE_JSON || "").trim(); + if (runTests && sdkTestsMatrixOverrideRaw !== "") { + effectiveSdkTestsMatrix = parseMatrixOverride(sdkTestsMatrixOverrideRaw, "sdk_tests_matrix_override_json"); + } + + const getRequiredPrimaryEntry = (matrix, label) => { + const primaryEntry = matrix.find( + (entry) => entry.runner === sdkTests.runner && entry.xcode === sdkTests.xcode + ); + + if (!primaryEntry) { + throw new Error(`${label} must include primary sdk_tests entry '${sdkTests.runner}' + '${sdkTests.xcode}'.`); + } + if (primaryEntry.informational) { + throw new Error(`${label} primary sdk_tests entry '${sdkTests.runner}' + '${sdkTests.xcode}' must have informational=false.`); + } + + return primaryEntry; + }; + + let primaryBuildEntry = null; + + if (runBuildSdkTargets || runBuildTestApp) { + primaryBuildEntry = getRequiredPrimaryEntry(effectiveBuildMatrix, "build_matrix"); + } + + if (runBuildTestApp) { + effectiveTestAppMatrix = [primaryBuildEntry]; + } + + const githubOutput = process.env.GITHUB_OUTPUT; + if (!githubOutput) { + throw new Error("GITHUB_OUTPUT is not set."); + } + + const setOutput = (name, value) => { + fs.appendFileSync(githubOutput, `${name}=${value}\n`); + }; + + setOutput("run_build_sdk_targets", String(runBuildSdkTargets)); + setOutput("run_build_test_app", String(runBuildTestApp)); + setOutput("run_tests", String(runTests)); + setOutput("run_lint_pods", String(runLintPods)); + setOutput("build_matrix_json", JSON.stringify({ include: effectiveBuildMatrix })); + setOutput("test_app_matrix_json", JSON.stringify({ include: effectiveTestAppMatrix })); + setOutput("sdk_tests_matrix_json", JSON.stringify({ include: effectiveSdkTestsMatrix })); + setOutput("sdk_runner", sdkTests.runner); + setOutput("sdk_xcode", sdkTests.xcode); +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/ci/sdk_validation/run-pod-lib-lint.sh b/scripts/ci/sdk_validation/run-pod-lib-lint.sh new file mode 100644 index 000000000..95cea33ee --- /dev/null +++ b/scripts/ci/sdk_validation/run-pod-lib-lint.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/logging.sh" + +usage() { + cat <<'EOF' +Usage: + run-pod-lib-lint.sh --log +EOF +} + +log_path="" +original_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + --log) + if [[ $# -lt 2 ]]; then + echo "Error: --log requires an argument" >&2 + exit 1 + fi + log_path="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$log_path" ]]; then + echo "Error: --log is required." >&2 + usage + exit 1 +fi + +ci_init_artifact_log "$(basename "$0")" "$log_path" "${original_args[@]}" +ci_log_section "Running pod lib lint" + +ci_run_logged_command \ + pod lib lint \ + Adapty.podspec \ + AdaptyUI.podspec \ + AdaptyPlugin.podspec \ + --allow-warnings \ + --skip-tests \ + --include-podspecs=Adapty.podspec,AdaptyUI.podspec,AdaptyPlugin.podspec,AdaptyLogger.podspec,AdaptyUIBuilder.podspec diff --git a/scripts/ci/sdk_validation/run-sdk-tests.sh b/scripts/ci/sdk_validation/run-sdk-tests.sh new file mode 100644 index 000000000..a8b9e7505 --- /dev/null +++ b/scripts/ci/sdk_validation/run-sdk-tests.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/logging.sh" + +usage() { + cat <<'EOF' +Usage: + run-sdk-tests.sh --log +EOF +} + +log_path="" +original_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + --log) + if [[ $# -lt 2 ]]; then + echo "Error: --log requires an argument" >&2 + exit 1 + fi + log_path="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$log_path" ]]; then + echo "Error: --log is required." >&2 + usage + exit 1 +fi + +if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + echo "Error: GITHUB_OUTPUT is not set." >&2 + exit 1 +fi + +ci_init_artifact_log "$(basename "$0")" "$log_path" "${original_args[@]}" + +ci_log_section "Resolving Swift package dependencies" +ci_run_logged_command swift package resolve + +ci_log_section "Running SDK tests" +exit_code=0 +ci_run_logged_command_capture_exit exit_code swift test + +printf 'exit_code=%s\n' "$exit_code" >> "$GITHUB_OUTPUT" diff --git a/scripts/ci/sdk_validation/write-demo-constants.sh b/scripts/ci/sdk_validation/write-demo-constants.sh new file mode 100644 index 000000000..d7150c3c5 --- /dev/null +++ b/scripts/ci/sdk_validation/write-demo-constants.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + write-demo-constants.sh [--output ] + +Default output: + Examples/AdaptyRecipes-SwiftUI/AdaptyRecipes-SwiftUI/Application/AppConstants.swift +EOF +} + +output_path="Examples/AdaptyRecipes-SwiftUI/AdaptyRecipes-SwiftUI/Application/AppConstants.swift" + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + if [[ $# -lt 2 ]]; then + echo "Error: --output requires an argument" >&2 + exit 1 + fi + output_path="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +mkdir -p "$(dirname "$output_path")" + +cat > "$output_path" <<'SWIFT' +// +// AppConstants.swift +// AdaptyRecipes-SwiftUI +// +// Auto-generated in CI for build validation. +// + +import Foundation + +enum AppConstants { + static let accessLevelId = "premium" + static let adaptyApiKey = "ci_dummy_api_key" + static let placementId = "ci_dummy_placement_id" +} +SWIFT