From 696989ad03f8a5578f46ed6f0a5ff72ba7d2363b Mon Sep 17 00:00:00 2001 From: Nikita Kupriyanov Date: Fri, 20 Mar 2026 15:52:37 +0000 Subject: [PATCH 1/5] Fix KingFisher build for Xcode 26.4 beta 2 Swift 6 strict concurrency --- .../Extensions/HasImageComponent+Kingfisher.swift | 11 ++++++----- .../Extensions/ImageView+Kingfisher.swift | 15 ++++++++++----- .../Extensions/NSTextAttachment+Kingfisher.swift | 6 ++++-- 3 files changed, 20 insertions(+), 12 deletions(-) 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 } } From ece48b8c0bdb2be70a355479aa95b754588f43bf Mon Sep 17 00:00:00 2001 From: Alexey Goncharov Date: Fri, 20 Mar 2026 15:53:27 +0000 Subject: [PATCH 2/5] update version to 3.15.7 --- Adapty.podspec | 2 +- AdaptyLogger.podspec | 2 +- AdaptyPlugin.podspec | 2 +- AdaptyUI.podspec | 2 +- AdaptyUIBuilder.podspec | 2 +- Sources.AdaptyPlugin/cross_platform.yaml | 2 +- Sources/Versions.swift | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) 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/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 From b751c42bccb04c97bcb6bab29a06571e4098a9f1 Mon Sep 17 00:00:00 2001 From: Alexey Goncharov Date: Wed, 25 Mar 2026 09:40:52 +0000 Subject: [PATCH 3/5] fix build Xcode 16.x --- .../UIBuilder/Logic/AdaptyVideoViewModel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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() + } } } From b0e00c44af424c9e2460f634348c2ff47fb8b5c9 Mon Sep 17 00:00:00 2001 From: Nikita Kupriyanov Date: Fri, 27 Feb 2026 07:04:22 +0300 Subject: [PATCH 4/5] updated config for testing improved logging, updated README refactored CI flow (TEMPORARY COMMENTED BROKEN TESTS) fixed config renamed workflow to SDK Validation, constrained iOS checks to primary Xcode, updated CI docs fixed config versiob reduced basic running time, renamed action refactored CI workflow and config, updated Xcode matrix and test whitelist, documented manual run flow fixed CI/CD flow (closed corner cases + improved simplicity) bugfix fixed README added new CI/CD jobs --- .github/ci/README.md | 330 +++++++++++++++ .github/ci/ci-run-config.json | 30 ++ .github/workflows/sdk-validation.yml | 375 ++++++++++++++++++ Tests/Placements/FallbackTests.swift | 13 +- scripts/ci/sdk_validation/build-demo-app.sh | 65 +++ .../build-spm-library-targets.sh | 159 ++++++++ scripts/ci/sdk_validation/lib/config.js | 200 ++++++++++ scripts/ci/sdk_validation/lib/logging.sh | 135 +++++++ .../ci/sdk_validation/list-library-targets.js | 73 ++++ scripts/ci/sdk_validation/prepare-config.js | 135 +++++++ scripts/ci/sdk_validation/run-pod-lib-lint.sh | 56 +++ scripts/ci/sdk_validation/run-sdk-tests.sh | 60 +++ .../ci/sdk_validation/write-demo-constants.sh | 56 +++ 13 files changed, 1686 insertions(+), 1 deletion(-) create mode 100644 .github/ci/README.md create mode 100644 .github/ci/ci-run-config.json create mode 100644 .github/workflows/sdk-validation.yml create mode 100644 scripts/ci/sdk_validation/build-demo-app.sh create mode 100644 scripts/ci/sdk_validation/build-spm-library-targets.sh create mode 100644 scripts/ci/sdk_validation/lib/config.js create mode 100644 scripts/ci/sdk_validation/lib/logging.sh create mode 100644 scripts/ci/sdk_validation/list-library-targets.js create mode 100644 scripts/ci/sdk_validation/prepare-config.js create mode 100644 scripts/ci/sdk_validation/run-pod-lib-lint.sh create mode 100644 scripts/ci/sdk_validation/run-sdk-tests.sh create mode 100644 scripts/ci/sdk_validation/write-demo-constants.sh 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..092460dc0 --- /dev/null +++ b/.github/ci/ci-run-config.json @@ -0,0 +1,30 @@ +{ + "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": "16.1", + "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/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 From 3c5dbe96b53c4a68bb44f470f4650c150ab4cf9a Mon Sep 17 00:00:00 2001 From: Stanislav Mayorov Date: Wed, 25 Mar 2026 02:58:42 -0700 Subject: [PATCH 5/5] added all available Xcode versions (26.0-26.3, 16.0-16.4) to CI build matrix --- .github/ci/ci-run-config.json | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/ci/ci-run-config.json b/.github/ci/ci-run-config.json index 092460dc0..7a1933440 100644 --- a/.github/ci/ci-run-config.json +++ b/.github/ci/ci-run-config.json @@ -10,10 +10,45 @@ "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": {