From 93d599ed45234eccaabe53a38d394fe9a6d1a9f4 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 10 Aug 2026 11:48:11 -0300 Subject: [PATCH 01/15] Initial release script --- .github/workflows/release-tag.yml | 63 ++++++++++++++ release_thin.sh | 134 ++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 .github/workflows/release-tag.yml create mode 100644 release_thin.sh diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..f312c6d --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,63 @@ +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: macos-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 + run: | + echo "đŸˇī¸ Creating tag ${{ steps.extract-version.outputs.version }}..." + git tag -a "${{ steps.extract-version.outputs.version }}" -m "Release ${{ steps.extract-version.outputs.version }}" + git push origin "${{ steps.extract-version.outputs.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/release_thin.sh b/release_thin.sh new file mode 100644 index 0000000..f4a54cf --- /dev/null +++ b/release_thin.sh @@ -0,0 +1,134 @@ +#!/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" + +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" + +# 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..." +sed -i '' "s/private static let version = \".*\"/private static let version = \"$VERSION\"/" SplitThin/Common/Version.swift + +# 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 + +echo "" +echo "🎉 Release preparation completed successfully!" +echo "" +echo "Next steps:" +echo "1. Open Harness Code (repo ios-thin-client, org PROD, project Harness_Split)" +echo " and create a PR from '$RELEASE_BRANCH' into '$TARGET_BRANCH'." +echo "2. After merging, the tag '$VERSION' is created and mirrored to GitHub." +echo "" From d3204bd6b33dfb8bb212d29366cd6517cabe77e9 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 10 Aug 2026 11:50:17 -0300 Subject: [PATCH 02/15] Changing permissions on script file --- release_thin.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 release_thin.sh diff --git a/release_thin.sh b/release_thin.sh old mode 100644 new mode 100755 From 0d78b64fd126e8541383e86750896a3a9de6a87a Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Mon, 10 Aug 2026 14:51:42 +0000 Subject: [PATCH 03/15] chore: Update version to 1.0.1-rc1 (#113) * c820c0 chore: Update version to 1.0.1-rc1 --- SplitThin/Common/Version.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SplitThin/Common/Version.swift b/SplitThin/Common/Version.swift index b78287f..9618a40 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-beta2" + private static let version = "1.0.1-rc1" static var semantic: String { version From 6d7ae7a1ac1e55c0fe8ebe6e83a26ac226edff37 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 10 Aug 2026 12:36:57 -0300 Subject: [PATCH 04/15] Added script guard to release just from Github --- release_thin.sh | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/release_thin.sh b/release_thin.sh index f4a54cf..9e198e3 100755 --- a/release_thin.sh +++ b/release_thin.sh @@ -9,6 +9,9 @@ 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 @@ -26,6 +29,23 @@ RELEASE_BRANCH="release/$VERSION" 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." @@ -124,11 +144,16 @@ else 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. Open Harness Code (repo ios-thin-client, org PROD, project Harness_Split)" -echo " and create a PR from '$RELEASE_BRANCH' into '$TARGET_BRANCH'." -echo "2. After merging, the tag '$VERSION' is created and mirrored to GitHub." +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 "" From d33ee40c3b671a24d39f8efc2343a90a8c329f51 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 12 Aug 2026 17:07:39 -0300 Subject: [PATCH 05/15] Common fixes for Swift 6 full compatiblity --- SplitThin/Common/CommonHelpers.swift | 4 ++++ SplitThin/Network/RetryableHttpClient.swift | 7 +++++-- SplitThin/Sync/Streaming/ThinNotification.swift | 10 +++++----- SplitThinTests/Helpers/CommonHelpers.swift | 7 +++++-- .../EvaluationFetchCoordinatorEventsTest.swift | 2 +- SplitThinTests/Network/Auth/AuthProviderTests.swift | 2 +- SplitThinTests/Network/RetryableHttpClientTests.swift | 6 +++--- .../Sync/EvaluationFetchCoordinatorTests.swift | 2 +- 8 files changed, 25 insertions(+), 15 deletions(-) diff --git a/SplitThin/Common/CommonHelpers.swift b/SplitThin/Common/CommonHelpers.swift index 597fc3d..c954b57 100644 --- a/SplitThin/Common/CommonHelpers.swift +++ b/SplitThin/Common/CommonHelpers.swift @@ -22,3 +22,7 @@ extension Array { } } } + +struct UncheckedSendableBox: @unchecked Sendable { + let value: Value +} diff --git a/SplitThin/Network/RetryableHttpClient.swift b/SplitThin/Network/RetryableHttpClient.swift index 3bd6063..361e65d 100644 --- a/SplitThin/Network/RetryableHttpClient.swift +++ b/SplitThin/Network/RetryableHttpClient.swift @@ -98,11 +98,13 @@ final class DefaultRetryableHttpClient: RetryableHttpClient, @unchecked Sendable } private func performRequest(endpoint: Endpoint, body: Data?) async throws -> HttpResponse { - try await withCheckedThrowingContinuation { continuation in + // HttpResponse comes from a dependency module not audited for Sendable, so it must be + // boxed to cross into this continuation's closure without tripping strict concurrency checks. + let boxed: UncheckedSendableBox = try await withCheckedThrowingContinuation { continuation in do { _ = try httpClient.sendRequest(endpoint: endpoint, parameters: nil, headers: endpoint.headers, body: body) .getResponse { response in - continuation.resume(returning: response) + continuation.resume(returning: UncheckedSendableBox(value: response)) } errorHandler: { error in continuation.resume(throwing: RetryableHttpError.networkError(error)) } @@ -110,6 +112,7 @@ final class DefaultRetryableHttpClient: RetryableHttpClient, @unchecked Sendable continuation.resume(throwing: RetryableHttpError.networkError(error)) } } + return boxed.value } private static let defaultUrlRequestSender: UrlRequestSender = { request in diff --git a/SplitThin/Sync/Streaming/ThinNotification.swift b/SplitThin/Sync/Streaming/ThinNotification.swift index fcd0ef7..6772ee8 100644 --- a/SplitThin/Sync/Streaming/ThinNotification.swift +++ b/SplitThin/Sync/Streaming/ThinNotification.swift @@ -18,7 +18,7 @@ enum ControlType: String, Decodable { case unknown } -class ThinNotification { +class ThinNotification: @unchecked Sendable { let type: ThinNotificationType let channel: String? let timestamp: Int64 @@ -52,7 +52,7 @@ enum UpdateStrategy: Int { case keyList = 2 } -class EvaluationUpdateNotification: ThinNotification { +class EvaluationUpdateNotification: ThinNotification, @unchecked Sendable { let changeNumber: Int64 let dataType: NotificationDataType? let updateStrategy: UpdateStrategy? @@ -75,7 +75,7 @@ class EvaluationUpdateNotification: ThinNotification { } } -class ThinControlNotification: ThinNotification { +class ThinControlNotification: ThinNotification, @unchecked Sendable { let controlType: ControlType init(channel: String?, timestamp: Int64, controlType: ControlType) { @@ -84,7 +84,7 @@ class ThinControlNotification: ThinNotification { } } -class ThinOccupancyNotification: ThinNotification { +class ThinOccupancyNotification: ThinNotification, @unchecked Sendable { let publishers: Int init(channel: String?, timestamp: Int64, publishers: Int) { @@ -93,7 +93,7 @@ class ThinOccupancyNotification: ThinNotification { } } -class ThinStreamingError: ThinNotification { +class ThinStreamingError: ThinNotification, @unchecked Sendable { let message: String let code: Int let statusCode: Int? diff --git a/SplitThinTests/Helpers/CommonHelpers.swift b/SplitThinTests/Helpers/CommonHelpers.swift index 6917edc..a3f138f 100644 --- a/SplitThinTests/Helpers/CommonHelpers.swift +++ b/SplitThinTests/Helpers/CommonHelpers.swift @@ -19,11 +19,14 @@ extension XCTestCase { } // Utility to improve testing legibility. - // If the expectation doesn't fulfill in 3 seconds, THE TEST FAILS. + // self and expectations are boxed because XCTestCase/XCTestExpectation aren't Sendable, but + // XCTest runs test methods serially, so sending them into this Task is safe. func waitFor(_ expectations: XCTestExpectation..., timeout: Double = 3) { + let testCase = UncheckedSendableBox(value: self) + let expectations = UncheckedSendableBox(value: expectations) let semaphore = DispatchSemaphore(value: 0) Task { - await fulfillment(of: expectations, timeout: timeout) + await testCase.value.fulfillment(of: expectations.value, timeout: timeout) semaphore.signal() } semaphore.wait() diff --git a/SplitThinTests/IntegrationTests/EvaluationFetchCoordinatorEventsTest.swift b/SplitThinTests/IntegrationTests/EvaluationFetchCoordinatorEventsTest.swift index c36e03b..a1edabf 100644 --- a/SplitThinTests/IntegrationTests/EvaluationFetchCoordinatorEventsTest.swift +++ b/SplitThinTests/IntegrationTests/EvaluationFetchCoordinatorEventsTest.swift @@ -1,7 +1,7 @@ import XCTest @testable import SplitThin -final class EvaluationFetchCoordinatorEventsTest: XCTestCase { +final class EvaluationFetchCoordinatorEventsTest: XCTestCase, @unchecked Sendable { private var provider: EvaluationProviderMock! private var coordinator: DefaultEvaluationFetchCoordinator! diff --git a/SplitThinTests/Network/Auth/AuthProviderTests.swift b/SplitThinTests/Network/Auth/AuthProviderTests.swift index a602e61..5600ab2 100644 --- a/SplitThinTests/Network/Auth/AuthProviderTests.swift +++ b/SplitThinTests/Network/Auth/AuthProviderTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import SplitThin -final class DefaultAuthProviderTest: XCTestCase { +final class DefaultAuthProviderTest: XCTestCase, @unchecked Sendable { private var storageMock: CredentialStorageMock! private var fetcherMock: CredentialFetcherMock! diff --git a/SplitThinTests/Network/RetryableHttpClientTests.swift b/SplitThinTests/Network/RetryableHttpClientTests.swift index 8ba0341..c3403cf 100644 --- a/SplitThinTests/Network/RetryableHttpClientTests.swift +++ b/SplitThinTests/Network/RetryableHttpClientTests.swift @@ -131,17 +131,17 @@ final class DefaultRetryableHttpClientTest: XCTestCase { ] let client = createClient(policies: policies) - let endpoint = createEndpoint() + let endpoint = UncheckedSendableBox(value: createEndpoint()) let task = Task { - try await client.execute(endpoint, category: .evaluations) + UncheckedSendableBox(value: try await client.execute(endpoint.value, category: .evaluations)) } try await Task.sleep(nanoseconds: 50_000_000) task.cancel() do { - _ = try await task.value + _ = try await task.value.value XCTFail("Expected cancellation error") } catch is CancellationError { XCTAssertLessThan(httpClientMock.requestCount, 100) diff --git a/SplitThinTests/Sync/EvaluationFetchCoordinatorTests.swift b/SplitThinTests/Sync/EvaluationFetchCoordinatorTests.swift index 8d06c50..badd95c 100644 --- a/SplitThinTests/Sync/EvaluationFetchCoordinatorTests.swift +++ b/SplitThinTests/Sync/EvaluationFetchCoordinatorTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import SplitThin -final class DefaultEvaluationFetchCoordinatorTest: XCTestCase { +final class DefaultEvaluationFetchCoordinatorTest: XCTestCase, @unchecked Sendable { private var provider: EvaluationProviderMock! private var coordinator: DefaultEvaluationFetchCoordinator! From 07b067c9d0a04088771c2baa6f171d2f4782bf10 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 13 Aug 2026 10:01:45 -0300 Subject: [PATCH 06/15] Sendable boxed to avoid breking change --- SplitThin/Events/SplitEventsManager.swift | 26 ++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) 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) } } } From 7c3f24e12cbf0ec07de80e1d49fb49dda7973f2c Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Fri, 14 Aug 2026 02:00:54 +0000 Subject: [PATCH 07/15] Apply suggestion from code review --- .github/workflows/release-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index f312c6d..3c5dc49 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -10,7 +10,7 @@ on: jobs: create-tag: if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') - runs-on: macos-latest + runs-on: ubuntu-latest outputs: version: ${{ steps.extract-version.outputs.version }} steps: From 6ebb6ecefe70e4248f880e13b60e31eb8896356c Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Fri, 14 Aug 2026 02:01:31 +0000 Subject: [PATCH 08/15] Apply suggestion from code review --- .github/workflows/release-tag.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 3c5dc49..e16872b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -44,10 +44,16 @@ jobs: echo "✅ Version.swift contains correct version: $VERSION_IN_FILE" - name: Create tag + env: + VERSION: ${{ steps.extract-version.outputs.version }} run: | - echo "đŸˇī¸ Creating tag ${{ steps.extract-version.outputs.version }}..." - git tag -a "${{ steps.extract-version.outputs.version }}" -m "Release ${{ steps.extract-version.outputs.version }}" - git push origin "${{ steps.extract-version.outputs.version }}" + 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: | From 710f11fd2cd0ede52c35f7d902b700da4d2954ce Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Fri, 14 Aug 2026 02:01:57 +0000 Subject: [PATCH 09/15] Apply suggestion from code review --- release_thin.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/release_thin.sh b/release_thin.sh index 9e198e3..e9c73a4 100755 --- a/release_thin.sh +++ b/release_thin.sh @@ -72,7 +72,12 @@ fi # Update Version.swift echo "📝 Updating Version.swift to $VERSION..." -sed -i '' "s/private static let version = \".*\"/private static let version = \"$VERSION\"/" SplitThin/Common/Version.swift +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 From 9ca97f3f861630acaae13d6bfd99f30ed9090b1f Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 13 Aug 2026 23:05:50 -0300 Subject: [PATCH 10/15] Script updated to check Swift 6 --- .../Harness_Split/pipelines/iosthinclienttest.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml index 592b57e..976dcf7 100644 --- a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml +++ b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml @@ -48,6 +48,18 @@ pipeline: set -euo pipefail chmod +x .harness/scripts/install-deps.sh .harness/scripts/install-deps.sh + - step: + type: Run + name: Swift 6 strict concurrency check + 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 + swift test -Xswiftc -swift-version -Xswiftc 6 - step: type: RunTests name: Run unit tests From 1fa244c70a952645db290b03fa24794b1e31f70a Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 13 Aug 2026 23:37:59 -0300 Subject: [PATCH 11/15] Fixing ghost concurrency bug on older Swift toolchains --- SplitThin/Storage/CoreDataStorage.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SplitThin/Storage/CoreDataStorage.swift b/SplitThin/Storage/CoreDataStorage.swift index 55fac06..3d8d7bd 100644 --- a/SplitThin/Storage/CoreDataStorage.swift +++ b/SplitThin/Storage/CoreDataStorage.swift @@ -403,15 +403,19 @@ 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 + // `T` is not Sendable, so box it before crossing the continuation boundary. Newer Swift + // toolchains accept the bare crossing via region-based isolation, but older ones (e.g. the CI + // runner) reject it as a hard error; the box keeps this portable across toolchains. + 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 From e01affd387a0caa9ab5d98c64bd3b22f6f6e0f0c Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 14 Aug 2026 00:29:02 -0300 Subject: [PATCH 12/15] Added keychain on CI for failing tests --- .../Harness_Split/pipelines/iosthinclienttest.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml index 976dcf7..b4a0669 100644 --- a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml +++ b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml @@ -59,6 +59,17 @@ pipeline: # 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: RunTests From c74712e5c7da8cd455534a6e2c164108a1fd7dd6 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 14 Aug 2026 00:43:15 -0300 Subject: [PATCH 13/15] Swift Test 5 & 6 finished --- .../pipelines/iosthinclienttest.yaml | 15 ++++++++++++++- SplitThin/Storage/CoreDataStorage.swift | 4 +--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml index b4a0669..0cd6ff6 100644 --- a/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml +++ b/.harness/orgs/PROD/projects/Harness_Split/pipelines/iosthinclienttest.yaml @@ -50,7 +50,7 @@ pipeline: .harness/scripts/install-deps.sh - step: type: Run - name: Swift 6 strict concurrency check + name: Build and Test (Swift 6 strict concurrency) identifier: swift6_strict_concurrency_check spec: shell: Sh @@ -71,6 +71,19 @@ pipeline: 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/Storage/CoreDataStorage.swift b/SplitThin/Storage/CoreDataStorage.swift index 3d8d7bd..cc0d48c 100644 --- a/SplitThin/Storage/CoreDataStorage.swift +++ b/SplitThin/Storage/CoreDataStorage.swift @@ -403,9 +403,7 @@ final class CoreDataStorage: @unchecked Sendable { private func withContext(_ block: @escaping (NSManagedObjectContext) throws -> T) async throws -> T { let context = container.newBackgroundContext() - // `T` is not Sendable, so box it before crossing the continuation boundary. Newer Swift - // toolchains accept the bare crossing via region-based isolation, but older ones (e.g. the CI - // runner) reject it as a hard error; the box keeps this portable across toolchains. + let boxed: UncheckedSendableBox = try await withCheckedThrowingContinuation { continuation in context.perform { do { From 414dd32dc9de0f0227d5a2791a059af9d4334e50 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Fri, 14 Aug 2026 14:06:18 +0000 Subject: [PATCH 14/15] Apply suggestion from code review --- release_thin.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release_thin.sh b/release_thin.sh index e9c73a4..d2f07f2 100755 --- a/release_thin.sh +++ b/release_thin.sh @@ -62,7 +62,7 @@ echo "📑 Current branch: $CURRENT_BRANCH" # Create release branch from current branch echo "đŸŒŋ Creating branch $RELEASE_BRANCH from $CURRENT_BRANCH..." -git checkout -b "$RELEASE_BRANCH" +git checkout -B "$RELEASE_BRANCH" # Any version with a "-" suffix (rc, beta, alpha...) is a pre-release IS_PRERELEASE=false From 8d1eddfa92bb5135eb0bd71244b8f5b502223d2c Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 14 Aug 2026 11:33:32 -0300 Subject: [PATCH 15/15] chore: Update version to 1.0.2-rc1 --- SplitThin/Common/Version.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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