diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..e16872b --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,69 @@ +name: Release Tagging + +on: + pull_request: + types: [closed] + branches: + - main + - development + +jobs: + create-tag: + if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract-version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Git identity + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" + + - name: Extract version from branch name + id: extract-version + run: | + BRANCH_NAME="${{ github.head_ref }}" + VERSION=${BRANCH_NAME#release/} + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + - name: Verify Version.swift contains correct version + run: | + VERSION_IN_FILE=$(grep -o 'private static let version = "[^"]*"' SplitThin/Common/Version.swift | cut -d'"' -f2) + if [ "$VERSION_IN_FILE" != "${{ steps.extract-version.outputs.version }}" ]; then + echo "❌ Error: Version in Version.swift ($VERSION_IN_FILE) does not match branch version (${{ steps.extract-version.outputs.version }})" + exit 1 + fi + echo "✅ Version.swift contains correct version: $VERSION_IN_FILE" + + - name: Create tag + env: + VERSION: ${{ steps.extract-version.outputs.version }} + run: | + if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "ℹ️ Tag $VERSION already exists locally — skipping create." + else + echo "🏷️ Creating tag $VERSION..." + git tag -a "$VERSION" -m "Release $VERSION" + fi + git push origin "$VERSION" + + - name: Verify tag in remote + run: | + echo "✅ Verifying tag ${{ steps.extract-version.outputs.version }} exists in remote..." + sleep 5 + git fetch --tags + + if git ls-remote --tags origin | grep -q "refs/tags/${{ steps.extract-version.outputs.version }}$"; then + echo "✅ Tag ${{ steps.extract-version.outputs.version }} successfully created in remote" + else + echo "❌ Failed to verify tag ${{ steps.extract-version.outputs.version }} in remote" + exit 1 + fi diff --git a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml index 592b57e..0cd6ff6 100644 --- a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml +++ b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml @@ -48,6 +48,42 @@ pipeline: set -euo pipefail chmod +x .harness/scripts/install-deps.sh .harness/scripts/install-deps.sh + - step: + type: Run + name: Build and Test (Swift 6 strict concurrency) + identifier: swift6_strict_concurrency_check + spec: + shell: Sh + command: | + set -euo pipefail + # Package.swift stays on swift-tools-version 5.5 (toolchain compat), so this + # is the only place that actually validates the SDK against Swift 6 strict mode. + swift build -Xswiftc -swift-version -Xswiftc 6 + + # Headless macOS runners have no unlocked login keychain, so generic-password + # items never round-trip and KeychainCredentialStorage persistence tests fail. + # Create and unlock a temporary default keychain for this session. + KEYCHAIN_PATH="$(pwd)/ci.keychain-db" + security create-keychain -p "" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "" "$KEYCHAIN_PATH" + security default-keychain -s "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + swift test -Xswiftc -swift-version -Xswiftc 6 + + - step: + type: Run + name: Build and Test (Swift 5) + identifier: swift5 + spec: + shell: Sh + command: | + set -euo pipefail + # Swift 5 is the mode we actually ship (Package.swift is swift-tools-version 5.5), + # so validate the shipped semantics first, then re-run under Swift 6 strict for + # forward-compat. + swift test - step: type: RunTests name: Run unit tests diff --git a/SplitThin/Common/Version.swift b/SplitThin/Common/Version.swift index 56e90e8..15eb30e 100644 --- a/SplitThin/Common/Version.swift +++ b/SplitThin/Common/Version.swift @@ -5,7 +5,7 @@ import Foundation enum Version { private static let sdkPlatform = "iOSThin" - private static let version = "1.0.0" + private static let version = "1.0.2-rc1" static var semantic: String { version diff --git a/SplitThin/Events/SplitEventsManager.swift b/SplitThin/Events/SplitEventsManager.swift index fa9029f..85a8dd9 100644 --- a/SplitThin/Events/SplitEventsManager.swift +++ b/SplitThin/Events/SplitEventsManager.swift @@ -64,12 +64,14 @@ final class DefaultSplitEventsManager: SplitEventsManager, @unchecked Sendable { guard let self else { return } self.appendListener(listener) + let boxedListener = UncheckedSendableBox(value: listener) + // Sticky events: replay already-fired state to late subscribers if let metadata = self.getReadyMetadata() { - DispatchQueue.main.async { listener.onReady(metadata) } + DispatchQueue.main.async { boxedListener.value.onReady(metadata) } } if let metadata = self.getCacheMetadata() { - DispatchQueue.main.async { listener.onReadyFromCache(metadata) } + DispatchQueue.main.async { boxedListener.value.onReadyFromCache(metadata) } } } } @@ -135,8 +137,9 @@ final class DefaultSplitEventsManager: SplitEventsManager, @unchecked Sendable { guard !isSdkReadyFired() else { return } setReadyMetadata(metadata) - getListeners().forEach { listener in - DispatchQueue.main.async { listener.onReady(metadata) } + getListeners().forEach { listener in + let boxedListener = UncheckedSendableBox(value: listener) + DispatchQueue.main.async { boxedListener.value.onReady(metadata) } } } @@ -144,8 +147,9 @@ final class DefaultSplitEventsManager: SplitEventsManager, @unchecked Sendable { guard !isSdkReadyFromCacheFired() else { return } setCacheMetadata(metadata) - getListeners().forEach { listener in - DispatchQueue.main.async { listener.onReadyFromCache(metadata) } + getListeners().forEach { listener in + let boxedListener = UncheckedSendableBox(value: listener) + DispatchQueue.main.async { boxedListener.value.onReadyFromCache(metadata) } } } @@ -153,16 +157,18 @@ final class DefaultSplitEventsManager: SplitEventsManager, @unchecked Sendable { guard !isSdkReadyTimedOutFired() else { return } setSdkReadyTimedOutFired() - getListeners().forEach { listener in - DispatchQueue.main.async { listener.onReadyTimedOut() } + getListeners().forEach { listener in + let boxedListener = UncheckedSendableBox(value: listener) + DispatchQueue.main.async { boxedListener.value.onReadyTimedOut() } } } private func triggerUpdate(_ metadata: SdkUpdateMetadata) { Logger.d("Triggering SDK event SDK_UPDATE") - getListeners().forEach { listener in - DispatchQueue.main.async { listener.onUpdate(metadata) } + getListeners().forEach { listener in + let boxedListener = UncheckedSendableBox(value: listener) + DispatchQueue.main.async { boxedListener.value.onUpdate(metadata) } } } diff --git a/SplitThin/Storage/CoreDataStorage.swift b/SplitThin/Storage/CoreDataStorage.swift index 55fac06..cc0d48c 100644 --- a/SplitThin/Storage/CoreDataStorage.swift +++ b/SplitThin/Storage/CoreDataStorage.swift @@ -403,15 +403,17 @@ final class CoreDataStorage: @unchecked Sendable { private func withContext(_ block: @escaping (NSManagedObjectContext) throws -> T) async throws -> T { let context = container.newBackgroundContext() - return try await withCheckedThrowingContinuation { continuation in + + let boxed: UncheckedSendableBox = try await withCheckedThrowingContinuation { continuation in context.perform { do { - continuation.resume(returning: try block(context)) + continuation.resume(returning: UncheckedSendableBox(value: try block(context))) } catch { continuation.resume(throwing: error) } } } + return boxed.value } // MARK: - Model Definition diff --git a/release_thin.sh b/release_thin.sh new file mode 100755 index 0000000..d2f07f2 --- /dev/null +++ b/release_thin.sh @@ -0,0 +1,164 @@ +#!/bin/bash + +# ios-thin-client Release Preparation Script +# Mirrors ios-client/scripts/release.sh. +# Usage: ./release_thin.sh +# Example: ./release_thin.sh 1.0.1-rc1 + +# Branch name constants - update these if branch naming changes +MASTER_BRANCH="main" +DEVELOPMENT_BRANCH="development" + +# Public GitHub repo (canonical for this public SDK; PRs/tags live here). +GITHUB_REPO="splitio/ios-thin-client" + +set -e + +# Check if version parameter is provided +if [ -z "$1" ]; then + echo "❌ Error: Version parameter is required" + echo "Usage: ./release_thin.sh " + echo "Example: ./release_thin.sh 1.0.1-rc1" + exit 1 +fi + +VERSION=$1 +RELEASE_BRANCH="release/$VERSION" + +# Ensure we're in the repo root directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# Releases must originate on the public GitHub repo (canonical). Harness Code is a +# downstream mirror, so pushing there would never reach GitHub / trigger the tag +# workflow. Refuse to run unless 'origin' points at the GitHub repo. +ORIGIN_URL="$(git config --get remote.origin.url || true)" +case "$ORIGIN_URL" in + *github.com[:/]"$GITHUB_REPO"* ) + : ;; # ok, origin is the GitHub repo + * ) + echo "❌ Error: 'origin' is not the GitHub repo ($GITHUB_REPO)." + echo " Current origin: ${ORIGIN_URL:-}" + echo " Releases must be run from the GitHub clone (the public repo is canonical;" + echo " Harness Code only mirrors from it). Clone and release from there:" + echo " git clone https://github.com/$GITHUB_REPO.git" + exit 1 + ;; +esac + +# Check if working directory is clean +if [ -n "$(git status --porcelain)" ]; then + echo "❌ Error: Working directory is not clean. Please commit or stash your changes first." + exit 1 +fi + +# Fetch latest changes from remote +echo "📥 Fetching latest changes from remote..." +git fetch origin + +# Get current branch +CURRENT_BRANCH=$(git symbolic-ref --short HEAD) +echo "📑 Current branch: $CURRENT_BRANCH" + +# Create release branch from current branch +echo "🌿 Creating branch $RELEASE_BRANCH from $CURRENT_BRANCH..." +git checkout -B "$RELEASE_BRANCH" + +# Any version with a "-" suffix (rc, beta, alpha...) is a pre-release +IS_PRERELEASE=false +if [[ "$VERSION" == *-* ]]; then + IS_PRERELEASE=true +fi + +# Update Version.swift +echo "📝 Updating Version.swift to $VERSION..." +VERSION_FILE="SplitThin/Common/Version.swift" +if ! grep -q 'private static let version = "[^"]*"' "$VERSION_FILE"; then + echo "❌ Error: could not find version line in $VERSION_FILE" + exit 1 +fi +sed -i '' "s/private static let version = \".*\"/private static let version = \"$VERSION\"/" "$VERSION_FILE" + +# Update CHANGES.txt if not a pre-release version +if [ "$IS_PRERELEASE" = false ]; then + echo "📝 Updating CHANGES.txt..." + + # Prompt for changes + echo "" + echo "Please enter the changes for version $VERSION (one per line)" + echo "Press Enter twice when done (or just press Enter to skip)" + echo "" + + CHANGES="" + while true; do + read -r line + + # Break on empty line + if [ -z "$line" ]; then + if [ -z "$CHANGES" ]; then + # No changes were entered, just break + break + else + # Confirm if done + read -r -p "Are you done entering changes? (y/n): " confirm + if [[ "$confirm" =~ ^[Yy] ]]; then + break + fi + fi + else + # Add the line to changes + if [ -z "$CHANGES" ]; then + CHANGES="- $line" + else + CHANGES="$CHANGES\n- $line" + fi + fi + done + + # Create the new entry + CURRENT_DATE=$(LC_ALL=C date "+%b %-d, %Y") + NEW_ENTRY="$VERSION ($CURRENT_DATE)" + if [ -n "$CHANGES" ]; then + NEW_ENTRY="$NEW_ENTRY\n$CHANGES" + fi + + # Insert at the beginning of the file + sed -i '' "1s/^/$NEW_ENTRY\n\n/" CHANGES.txt +fi + +# Commit changes +echo "💾 Committing changes..." +if [ "$IS_PRERELEASE" = false ]; then + git add SplitThin/Common/Version.swift CHANGES.txt + git commit -m "chore: Update version to $VERSION and update CHANGES.txt" +else + git add SplitThin/Common/Version.swift + git commit -m "chore: Update version to $VERSION" +fi + +# Push changes +echo "📤 Pushing branch to remote..." +git push origin "$RELEASE_BRANCH" + +# Determine target branch based on pre-release status +if [ "$IS_PRERELEASE" = true ]; then + TARGET_BRANCH="$DEVELOPMENT_BRANCH" + echo "📊 Pre-release version detected, PR will target the $DEVELOPMENT_BRANCH branch" +else + TARGET_BRANCH="$MASTER_BRANCH" + echo "📊 Regular version detected, PR will target the $MASTER_BRANCH branch" +fi + +# Create PR URL on the public GitHub repo (canonical; Harness Code mirrors from it) +PR_URL="https://github.com/$GITHUB_REPO/compare/$TARGET_BRANCH...$RELEASE_BRANCH?expand=1" + +echo "" +echo "🎉 Release preparation completed successfully!" +echo "" +echo "Opening browser to create pull request..." +open "$PR_URL" 2>/dev/null || echo "Open this URL to create the PR: $PR_URL" +echo "" +echo "Next steps:" +echo "1. Complete the pull request to merge $RELEASE_BRANCH into $TARGET_BRANCH on GitHub." +echo "2. After merging, the release-tag workflow creates and pushes the tag '$VERSION'." +echo ""