From 1034d2e508048dfb0b730bbcc217d99ef7e98f75 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 15:53:15 -0800 Subject: [PATCH] github actions script --- .github/workflows/swift-sdk-publish.yaml | 100 ++++++++++++++++++ .../swift/Examples/StackAuthiOS/README.md | 8 +- sdks/implementations/swift/README.md | 5 +- .../swift/Sources/StackAuth/APIClient.swift | 20 +++- .../StackAuth/Models/CurrentUser.swift | 2 +- .../Sources/StackAuth/Models/Permission.swift | 8 ++ .../Sources/StackAuth/Models/Session.swift | 9 +- .../swift/Sources/StackAuth/Models/Team.swift | 2 +- .../Tests/StackAuthTests/TestConfig.swift | 2 +- sdks/spec/src/_utilities.spec.md | 2 +- sdks/spec/src/types/common/api-keys.spec.md | 2 +- sdks/spec/src/types/teams/team.spec.md | 2 +- .../spec/src/types/users/current-user.spec.md | 6 +- 13 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/swift-sdk-publish.yaml diff --git a/.github/workflows/swift-sdk-publish.yaml b/.github/workflows/swift-sdk-publish.yaml new file mode 100644 index 000000000..4532a429f --- /dev/null +++ b/.github/workflows/swift-sdk-publish.yaml @@ -0,0 +1,100 @@ +name: Publish Swift SDK to prerelease repo + +on: + push: + branches: + - main + paths: + - 'sdks/implementations/swift/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false # Don't cancel publishing in progress + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + with: + path: source + + - name: Read version from package.json + id: version + run: | + VERSION=$(jq -r '.version' source/sdks/implementations/swift/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Swift SDK version: $VERSION" + + - name: Check if tag already exists in target repo + id: check-tag + run: | + TAG="v${{ steps.version.outputs.version }}" + echo "Checking if tag $TAG exists in stack-auth/swift-sdk-prerelease..." + + # Use the GitHub API to check if the tag exists + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/stack-auth/swift-sdk-prerelease/git/refs/tags/$TAG") + + if [ "$HTTP_STATUS" = "200" ]; then + echo "Tag $TAG already exists, skipping publish" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "Tag $TAG does not exist, will publish" + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Clone target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + git clone https://x-access-token:${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}@github.com/stack-auth/swift-sdk-prerelease.git target + + - name: Copy Swift SDK to target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + # Remove all files except .git from target + cd target + find . -maxdepth 1 -not -name '.git' -not -name '.' -exec rm -rf {} + + cd .. + + # Copy everything from Swift SDK + cp -r source/sdks/implementations/swift/* target/ + cp source/sdks/implementations/swift/.gitignore target/ 2>/dev/null || true + + # Remove package.json (it's only for turborepo integration, not part of the Swift package) + rm -f target/package.json + + - name: Commit and push to target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + cd target + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + git add -A + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Release v${{ steps.version.outputs.version }}" + fi + + # Create and push tag + TAG="v${{ steps.version.outputs.version }}" + git tag "$TAG" + git push origin main --tags + + echo "Successfully published Swift SDK v${{ steps.version.outputs.version }}" + + - name: Summary + run: | + if [ "${{ steps.check-tag.outputs.exists }}" = "true" ]; then + echo "::notice::Skipped publishing - tag v${{ steps.version.outputs.version }} already exists" + else + echo "::notice::Published Swift SDK v${{ steps.version.outputs.version }} to stack-auth/swift-sdk-prerelease" + fi diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md index 6b425829f..b05669a11 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/README.md +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -17,18 +17,18 @@ An interactive iOS application for testing all Stack Auth Swift SDK functions. open StackAuthiOS.xcodeproj ``` -2. Select an iOS Simulator (e.g., "iPhone 17 Pro") as the destination +2. Select an iOS Simulator (e.g., "iPhone 15 Pro" or any available device) as the destination 3. Press ⌘R to build and run ### Option 2: Command Line ```bash -# Build -xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +# Build (replace device name with an available simulator on your system) +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build # Build and run (opens simulator) -xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' run +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' run ``` ## Features diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index c6c3fb9d3..623fa8d2a 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -13,7 +13,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/stack-auth/stack-swift", from: "1.0.0") + .package(url: "https://github.com/stack-auth/swift-sdk-prerelease", from: ) ] ``` @@ -36,7 +36,7 @@ if let user = try await stack.getUser() { } // Sign out -try await user.signOut() +try await stack.signOut() ``` ## Design Decisions @@ -126,7 +126,6 @@ Task { | OAuth | Browser redirect | ASWebAuthenticationSession | | Redirect methods | Available | Not available (browser-only) | | React hooks | `useUser()` etc. | Not applicable | -| Error handling | Result types | `throws` | ### Not Available in Swift diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index e7aa3a389..fe5d3b203 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -35,7 +35,9 @@ actor APIClient { authenticated: Bool = false, serverOnly: Bool = false ) async throws -> (Data, HTTPURLResponse) { - let url = URL(string: "\(baseUrl)/api/v1\(path)")! + guard let url = URL(string: "\(baseUrl)/api/v1\(path)") else { + throw StackAuthError(code: "INVALID_URL", message: "Failed to construct request URL from base: \(baseUrl) and path: \(path)") + } var request = URLRequest(url: url) request.httpMethod = method request.cachePolicy = .reloadIgnoringLocalCacheData @@ -119,13 +121,23 @@ actor APIClient { } } - // Handle rate limiting - if actualStatus == 429 { + // Handle rate limiting (max 5 retries) + if actualStatus == 429 && attempt < 5 { if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"), let seconds = Double(retryAfter) { + // Use Retry-After header if provided try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } else { + // No Retry-After header: use exponential backoff (1s, 2s, 4s, 8s, 16s) + let delayMs = 1000.0 * pow(2.0, Double(attempt)) + try await Task.sleep(nanoseconds: UInt64(delayMs * 1_000_000)) } + return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } + + // Rate limit exhausted after max retries + if actualStatus == 429 { + throw StackAuthError(code: "RATE_LIMITED", message: "Too many requests, please try again later") } // Check for known error diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift index 92d0915f0..9d1d01dfb 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -336,7 +336,7 @@ public actor CurrentUser { ) async throws -> UserApiKeyFirstView { var body: [String: Any] = ["description": description] if let expiresAt = expiresAt { - body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) } if let scope = scope { body["scope"] = scope } if let teamId = teamId { body["team_id"] = teamId } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift index cdb2a8492..b1fc78b70 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift @@ -3,9 +3,17 @@ import Foundation /// A permission granted to a user within a team or project public struct TeamPermission: Sendable { public let id: String + + public init(id: String) { + self.id = id + } } /// A project-level permission public struct ProjectPermission: Sendable { public let id: String + + public init(id: String) { + self.id = id + } } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift index 7e5fc316a..6af001c81 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift @@ -14,13 +14,14 @@ public struct ActiveSession: Sendable { self.id = json["id"] as? String ?? "" self.userId = json["user_id"] as? String ?? "" - let createdMillis = json["created_at"] as? Int64 ?? json["created_at_millis"] as? Int64 ?? 0 - self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + // JSONSerialization returns NSNumber for numeric values, use doubleValue for reliable parsing + let createdMillis = (json["created_at"] as? NSNumber)?.doubleValue ?? 0 + self.createdAt = Date(timeIntervalSince1970: createdMillis / 1000.0) self.isImpersonation = json["is_impersonation"] as? Bool ?? false - if let lastUsedMillis = json["last_used_at"] as? Int64 ?? json["last_used_at_millis"] as? Int64 { - self.lastUsedAt = Date(timeIntervalSince1970: Double(lastUsedMillis) / 1000.0) + if let lastUsedRaw = json["last_used_at"] as? NSNumber { + self.lastUsedAt = Date(timeIntervalSince1970: lastUsedRaw.doubleValue / 1000.0) } else { self.lastUsedAt = nil } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift index 598bde101..d2b0ac2f9 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift @@ -133,7 +133,7 @@ public actor Team { ) async throws -> TeamApiKeyFirstView { var body: [String: Any] = ["description": description] if let expiresAt = expiresAt { - body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) } if let scope = scope { body["scope"] = scope } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index 168fa14c2..bc073ea92 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -25,7 +25,7 @@ struct TestConfig { do { let (_, response) = try await URLSession.shared.data(from: url) if let httpResponse = response as? HTTPURLResponse { - return httpResponse.statusCode < 500 + return (200..<300).contains(httpResponse.statusCode) } return false } catch { diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index da5aaf860..8b22377e1 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -215,7 +215,7 @@ Use getAuthHeaders() to generate this header value. Several sign-in methods may return MultiFactorAuthenticationRequired error when MFA is enabled. Error format: - code: "multi_factor_authentication_required" + code: "MULTI_FACTOR_AUTHENTICATION_REQUIRED" message: "Multi-factor authentication is required." details: { attempt_code: string } diff --git a/sdks/spec/src/types/common/api-keys.spec.md b/sdks/spec/src/types/common/api-keys.spec.md index d0cc8f619..c32ccf1b9 100644 --- a/sdks/spec/src/types/common/api-keys.spec.md +++ b/sdks/spec/src/types/common/api-keys.spec.md @@ -38,7 +38,7 @@ Does not error. options.description: string? options.expiresAt: Date | null? -PATCH /api/v1/api-keys/{id} { description, expires_at } [authenticated] +PATCH /api/v1/api-keys/{id} { description, expires_at_millis } [authenticated] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 395f32bed..6373bf838 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -102,7 +102,7 @@ options.scope: string? Returns: TeamApiKeyFirstView -POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] +POST /api/v1/teams/{teamId}/api-keys { description, expires_at_millis, scope } [authenticated] See types/common/api-keys.spec.md for TeamApiKeyFirstView. The apiKey property is only returned once at creation time. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index 8c65e00d1..c6ec04bfb 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -5,8 +5,8 @@ The authenticated user with methods to modify their own data. Extends: User (base-user.spec.md) Also includes: - - Auth methods (signOut, getAccessToken, etc.) - - Customer methods (payments/customer.spec.md) +- Auth methods (signOut, getAccessToken, etc.) +- Customer methods (payments/customer.spec.md) ## Additional Properties @@ -404,7 +404,7 @@ options.teamId: string? - for team-scoped keys Returns: UserApiKeyFirstView -POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] +POST /api/v1/users/me/api-keys { description, expires_at_millis, scope, team_id } [authenticated] See types/common/api-keys.spec.md for UserApiKeyFirstView. The apiKey property is only returned once at creation time.