Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building Scalable Swift Automation: From CI to...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Building Scalable Swift Automation: From CI to App Store Distribution

Managing Swift projects at scale demands systematic automation—from CI to multi-platform testing to App Store distribution. This hands-on 80-minute workshop teaches automation patterns refined across 128+ repositories, from open-source libraries to commercial apps.

Part 1 (35 min): Reduce GitHub Actions configs from 100+ lines to 5, add intelligent caching for 65–85% faster builds, and run matrix testing across Swift versions and Apple platforms—implemented live in your own repo.

Part 2 (35 min): Build packages that work across iOS, macOS, watchOS, tvOS, visionOS, Linux, and Android, using Swift Testing's conditional traits to skip platform-specific tests gracefully. We close with a TestFlight distribution demo (Fastlane + GitHub Actions, 30 minutes down to 3).

Bring a laptop, a GitHub account, and a Swift package or app. You leave with a working pipeline.

Avatar for leogdion

leogdion

October 09, 2026

Other Decks in Programming

Transcript

  1. Admin Account GitHub a a Admin Account Apple Developer Progr

    m Apple Store Connect @[email protected] brightdigit.com a Admin Account m cOS 26.4 Admin Account Xcode 26.4 or newer m cOS User or newer
  2. Source Code Resources Swift Packages Signing Xcode Project Distribution Certi

    icate Distribution Archive Store Assets Store Assets TestFlight App Store Review Ready for Sale f f @[email protected] brightdigit.com Provisioning Pro ile
  3. Listing Copy App Name Subtitle URLs Media Marketing URL Screenshots

    Compliance Privacy Details Description Support URL App Previews Age Rating Keywords Privacy Policy URL In-App Purchases Review Promotional Text Review Information @[email protected] brightdigit.com App Store Review
  4. Who Am I Leo Dion @leogdion [email protected] • Running BrightDigit

    for 14 ye rs • 25 ye rs of experience in Softw re Development • 15 ye rs of experience on Apple Pl tforms a a a a a a • 12 ye rs of experience in Swift
  5. .mise.toml homebrew / curl app store / xcodes / web

    mise tuist ruby Xcode SwiftLint Bundler f @[email protected] brightdigit.com Fastlane swift-format Gem ile
  6. • Continuous Integr tion • Isol ted Environment • Controlled

    Inst ll tion • Swift P ck ges • Multi L ngu ge Tools a a a a a a a a @[email protected] brightdigit.com
  7. HomeBrew • Continuous Integr tion • Isol ted Environment •

    Controlled Inst ll tion • Swift P ck ges • Multi L ngu ge Tools a a a a a a a a @[email protected] brightdigit.com
  8. Swift Packages Plugin • Continuous Integr tion • Isol ted

    Environment • Controlled Inst ll tion • Swift P ck ges • Multi L ngu ge Tools a a a a a a a a @[email protected] brightdigit.com 🔌
  9. Mint • Continuous Integr tion • Isol ted Environment •

    Controlled Inst ll tion • Swift P ck ges • Multi L ngu ge Tools a a a a a a a a @[email protected] brightdigit.com 🌱
  10. mise • Continuous Integr tion • Isol ted Environment •

    Controlled Inst ll tion • Swift P ck ges • Multi L ngu ge Tools a a a a a a a a @[email protected] brightdigit.com
  11. .mise.toml Mise [settings] # Enable experimental features (required for spm

    backend) experimental = true [tools] # Swift tools via SPM "spm:swiftlang/swift-format" = "602.0.0" "spm:peripheryapp/periphery" = "3.1.0" "spm:apple/swift-openapi-generator" = "1.7.0" @[email protected] brightdigit.com # Linting (via core or aqua) swiftlint = "0.63.2"
  12. .mise.toml Mise [settings] # Enable experimental features (required for spm

    backend) experimental = true [tools] # Swift tools via SPM "spm:swiftlang/swift-format" = "602.0.0" "spm:peripheryapp/periphery" = "3.1.0" "spm:apple/swift-openapi-generator" = "1.7.0" @[email protected] brightdigit.com # Linting (via core or aqua) swiftlint = "0.63.2" node = "lts/iron"
  13. .mise.toml Mise [settings] # Enable experimental features (required for spm

    backend) experimental = true [tools] # Swift tools via SPM "spm:swiftlang/swift-format" = "602.0.0" # Linting (via core or aqua) swiftlint = "0.63.2" ruby = "3.3.7" @[email protected] brightdigit.com
  14. mise install mise trust mise exec swiftlint -- swiftlint lint

    mise list echo 'eval "$(mise activate zsh)"' >> ~/.zshrc # zsh echo 'eval "$(mise activate bash)"' >> ~/.bashrc # bash swiftlint lint @[email protected] brightdigit.com
  15. Tuist [settings] # Enable experimental features (required for spm backend)

    experimental = true [tools] # Swift tools via SPM "spm:swiftlang/swift-format" = "602.0.0" # Linting (via core or aqua) swiftlint = "0.63.2" ruby = "3.3.7" @[email protected] brightdigit.com
  16. Tuist [settings] # Enable experimental features (required for spm backend)

    experimental = true [tools] # Swift tools via SPM "spm:swiftlang/swift-format" = "602.0.0" @[email protected] brightdigit.com # Linting (via core or aqua) swiftlint = "0.63.2" ruby = "3.3.7" tuist = “4.188.1"
  17. // !$*UTF8*$! { archiveVersion = 1; classes = { };

    objectVersion = 55; objects = { /* Begin PBXBuildFile section */ 0CA55A59A56E57B1DC2324BF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C26E82EA 0DCF51B3EA633E092B90A1DC /* TuistBundle+MonthBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96285 105C94F4A8385395DFD02E2D /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5B4A3558BEB33303A0D 29E86C22BEC8B1607B859646 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 840B340A7BF2F6514 B543EACD40D817B212020AD7 /* MonthBarApp in Frameworks */ = {isa = PBXBuildFile; productRef = 1B89B5EE08E227 DA80AC0675EB1FA8A025E361 /* MonthBarKit in Frameworks */ = {isa = PBXBuildFile; productRef = 12018C478B86BD /* End PBXBuildFile section */ 🤓 /* Begin PBXCopyFilesBuildPhase section */ E94B835A262EAECCA7085B59 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 37F252BA6888C552C9EA1053 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfi 4DB90D90AF58B878C6BDBDFA /* MonthBarKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Mo 840B340A7BF2F65144326DF3 /* AppIcon.icon */ = {isa = PBXFileReference; path = AppIcon.icon; sourceTree = "< 89BBD8E6899A429E3E771D0E /* MonthBar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.pl 92B101632F045E0CF67E65EA /* MonthBar-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plis @[email protected] 962859E2A4EDA1425E129EB7 /* TuistBundle+MonthBar.swift */ = {isa = PBXFileReference; lastKnownFileType = so brightdigit.com ADBCF9444E1BCC4F00437BC6 /* MonthBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.applicati
  18. // !$*UTF8*$! { /* Begin PBXBuildFile section */ /* End

    PBXBuildFile section */ 🙈 /* Begin PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @[email protected] brightdigit.com
  19. import ProjectDescription let project = Project( name: "Lumemo", targets: [

    .target( name: "Lumemo", destinations: [.iPhone, .iPad], product: .app, bundleId: "com.brightdigit.Lumemo", deploymentTargets: .iOS("17.0"), infoPlist: .extendingDefault(with: [ "CFBundleShortVersionString": "1.0", "CFBundleVersion": "1", "UILaunchScreen": .dictionary([:]), ]), sources: ["Sources/**"] ) ] ) // !$*UTF8*$! { /* Begin PBXBuildFile section */ /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section * /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @[email protected] brightdigit.com
  20. import SwiftUI @main struct LumemoMainApp: App { var body: some

    Scene { WindowGroup { ContentView() } } } @[email protected] brightdigit.com
  21. import SwiftUI @main struct LumemoMainApp: App { var body: some

    Scene { WindowGroup { ContentView() } } } @[email protected] brightdigit.com
  22. import SwiftUI import LumemoKit public protocol LumemoApp: App {} extension

    LumemoApp { // make sure this is public public var body: some Scene { WindowGroup { ContentView() } } } @[email protected] brightdigit.com import LumemoApp import SwiftUI @main internal struct LumemoMainApp: LumemoApp {}
  23. import SwiftUI @main struct LumemoMainApp: App { @UIApplicationDelegateAdaptor var appDelegate:

    LumemoAppDelegate } @[email protected] brightdigit.com var body: some Scene { WindowGroup { ContentView() } }
  24. import SwiftUI import UIKit import LumemoKit public final class LumemoAppDelegate:

    NSObject, UIApplicationDelegate { public func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { return true } } @[email protected] brightdigit.com public protocol LumemoApp: App { var appDelegate: LumemoAppDelegate { get } } extension LumemoApp { // make sure this is public public var body: some Scene { WindowGroup { ContentView() } } }
  25. import LumemoApp import SwiftUI @main internal struct LumemoMainApp: LumemoApp {

    @UIApplicationDelegateAdaptor internal var appDelegate : LumemoAppDelegate } internal init() {} @[email protected] brightdigit.com
  26. .swift LumemoKit SharedCode .swift LumemoKit Packages LumemoKitTests SharedCodeTests .swift LumemoApp

    LumemoApp .swift LumemoApp Project.swift Lumemo.xcodeproj Sources .swift App @[email protected] brightdigit.com LumemoAppTests LumemoAppTests
  27. 1. Inst ll the Ruby toolch in loc lly 2.

    Sc old f stl ne in the project 3. Gener te App Store Connect API Key 4. Cre te priv te certi ic tes repository 5. Cre te M tch ile 6. Author .env.def ult 7. Register App 8. Sync Certi ic tes nd Provisioning Pro iles 9. Upd te Tuist Project.swift 10. Verify Setup with rele se_build 11. On C p bilities Ch nge, Regener te Certi ic tes nd Pro iles f a a f f a a a a f a a a a f a a a f a a a a a a a ff a a a a @[email protected] brightdigit.com
  28. Setting Up Fastlane [settings] # Enable experimental features (required for

    spm backend) experimental = true [tools] # Swift tools via SPM "spm:swiftlang/swift-format" = "602.0.0" # Linting (via core or aqua) swiftlint = "0.63.2" ruby = "3.3.7" @[email protected] brightdigit.com
  29. private_lane :setup_api_key do if ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"] require 'base64' app_store_connect_api_key( key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],

    issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], key_content: Base64.decode64(ENV.fetch("APP_STORE_CONNECT_API_KEY_KEY")) ) end end @[email protected] brightdigit.com
  30. Match le Run Match Init mise exec -- bundle exec

    fastlane match init • Need our priv te repo URL • MATCH_PASSWORD fi a @[email protected] brightdigit.com
  31. # Required: app name (xcodebuild scheme + ASC display name)

    APP_NAME=Lumemo # Required: platform configuration — change both together PLATFORM=ios PRODUCE_PLATFORM=ios @[email protected] brightdigit.com
  32. # Run once per app, before any signing work. Wraps

    fastlane's `produce` action, # which calls the App Store Connect API to register the bundle ID in both the # Developer Portal and App Store Connect. Idempotent — re-running on an # already-registered app is a no-op. desc "Register app in Apple Developer Portal and App Store Connect" lane :setup_app do app_name = ENV.fetch("APP_NAME") app_identifier = CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) username = CredentialsManager::AppfileConfig.try_fetch_value(:apple_id) team_id = CredentialsManager::AppfileConfig.try_fetch_value(:team_id) itc_team_id = CredentialsManager::AppfileConfig.try_fetch_value(:itc_team_id) produce( username: username, app_identifier: app_identifier, app_name: app_name, platform: ENV.fetch("PRODUCE_PLATFORM"), team_id: team_id, itc_team_id: itc_team_id, skip_itc: false # also create the App Store Connect record ) UI.success("✅ App registered in Developer Portal and App Store Connect!") end @[email protected] brightdigit.com
  33. # One signing lane, parameterized by stage. Pulls the matching

    signing # identity + provisioning profile from the encrypted certs repo and # installs them in the local keychain. desc "Sync certificates and provisioning profiles (stage: development|appstore)" lane :sync_certificates do |options| stage = options.fetch(:stage) UI.user_error!("stage must be 'development' or 'appstore'") unless %w[development appstore].include?(stage) setup_ci if ENV['CI'] setup_api_key match_opts = { type: stage, readonly: ENV["CI"] == "true", app_identifier: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) } # macOS apps need an extra cert to sign the .pkg installer for the Mac # App Store. iOS/tvOS/watchOS .ipa bundles don't need this. if stage == "appstore" && ENV.fetch("PLATFORM") == "mac" match_opts[:additional_cert_types] = ["mac_installer_distribution"] end match(**match_opts) UI.success("✅ Certificates synced (stage: #{stage})!") end @[email protected] brightdigit.com
  34. desc "Sync both development and appstore certificates" lane :sync_all_certificates do

    sync_certificates(stage: "development") sync_certificates(stage: "appstore") UI.success("✅ All certificates synced!") end @[email protected] brightdigit.com
  35. settings: .settings( base: [ "DEVELOPMENT_TEAM": “XXXXXXXXXX", ], configurations: [ .debug(xcconfig:

    .relativeToRoot("Config/Version.xcconfig")), .release(xcconfig: .relativeToRoot("Config/Version.xcconfig")), ] ) @[email protected] brightdigit.com
  36. settings: .settings( base: [ "DEVELOPMENT_TEAM": "XXXXXXXXXX", "CODE_SIGN_STYLE": "Manual", ], configurations:

    [ .debug( settings: [ "CODE_SIGN_IDENTITY": "Apple Development", "PROVISIONING_PROFILE_SPECIFIER": "match Development com.brightdigit.Lumemo", ], xcconfig: .relativeToRoot("Config/Version.xcconfig") ), .release( settings: [ "CODE_SIGN_IDENTITY": "Apple Distribution", "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.brightdigit.Lumemo", ], xcconfig: .relativeToRoot("Config/Version.xcconfig") ), ] ) @[email protected] brightdigit.com
  37. desc "Build and sign a release archive (no upload)" lane

    :archive do app_name = ENV.fetch("APP_NAME") setup_api_key setup_ci if ENV['CI'] sync_certificates(stage: "appstore") # ensure App Store identity is in the keychain clear_derived_data build_app( scheme: app_name, configuration: "Release", clean: true, export_method: "app-store", # signs the artifact for App Store upload output_directory: "./build/release", output_name: app_name, xcargs: ENV["FASTLANE_XCARGS"] # lets CI inject extra xcodebuild args ) UI.success("✅ Release build complete!") end @[email protected] brightdigit.com
  38. desc "Force regenerate provisioning profiles (stage: development|appstore)" lane :regenerate_profiles do

    |options| stage = options.fetch(:stage) UI.user_error!("stage must be 'development' or 'appstore'") unless %w[development appstore].include?(stage) setup_api_key match( type: stage, force: true, app_identifier: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) ) UI.success("✅ Provisioning profiles regenerated (stage: #{stage})!") end @[email protected] brightdigit.com
  39. desc "Force regenerate provisioning profiles (stage: development|appstore)" lane :regenerate_profiles do

    |options| stage = options.fetch(:stage) UI.user_error!("stage must be 'development' or 'appstore'") unless %w[development appstore].include?(stage) setup_api_key match( type: stage, force: true, app_identifier: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) ) UI.success("✅ Provisioning profiles regenerated (stage: #{stage})!") end desc "Force regenerate all provisioning profiles (development and appstore)" lane :regenerate_all_profiles do regenerate_profiles(stage: "development") regenerate_profiles(stage: "appstore") UI.success("✅ All provisioning profiles regenerated!") end @[email protected] brightdigit.com
  40. name: Lumemo CI # on: <trigger> # When does this

    workflow run? # Typically on every push to main and on every pull request targeting main. on: # jobs: run in parallel by default. # Each job picks its own runner and its own ordered list of steps. jobs: # ── job: test-ubuntu ──────────────────────────────────────────────── # Build and test LumemoKit on Linux to catch platform-specific issues # early. # Runner + container: # - runs-on: a Linux runner # - container: `swift:6.2-noble` (the Swift toolchain *is* the image) # steps: (sequential) # - uses: check out the repo # - uses: `brightdigit/swift-build@v1` against the LumemoKit package test-ubuntu: # ── job: test-macos ───────────────────────────────────────────────── # Build and test LumemoKit on macOS — the canonical Apple-platform # check. # Runner: # - runs-on: `macos-26` (GitHub-hosted, free for public repos) # steps: (sequential) # - uses: check out the repo # - uses: `brightdigit/swift-build@v1` against the LumemoKit package # (pass `xcode:` so it uses xcodebuild for the SPM build) test-macos: @[email protected] brightdigit.com
  41. .github/work lows/your-work low.yml on: <trigger> jobs: (run in parallel) step:

    uses: <action> f f @[email protected] brightdigit.com job A job B steps: (sequential) steps: (sequential) step: run: <command>
  42. # .github/workflows/ci.yml — single job for now. # Everything else

    in this workflow is build/test jobs (added in §4–§7) and a # lint job (added in §5–§6). The fastlane job runs after them. name: CI # Run on PRs and on every push to main. The lane picked below depends on # which branch the workflow is running for. on: push: branches: [main] pull_request: branches: [main] jobs: fastlane: runs-on: [self-hosted, macOS] steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v4 with: install: true cache: true - name: Cache RubyGems uses: actions/cache@v5 with: path: vendor/ruby key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-gems- name: Setup Ruby run: | ruby --version gem install bundler bundle install - name: Setup Xcode Project run: tuist generate env: DEVELOPER_DIR: /Applications/Xcode.app # `archive` on PRs (build + sign, no upload — catches release-path failures early); # `beta` on main (archive + upload_to_testflight). Both lanes live in the Fastfile. - name: Archive (and upload on main) run: bundle exec fastlane ${{ github.ref == 'refs/heads/main' && 'beta' || 'archive' }} env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} FASTLANE_KEYCHAIN_PASSWORD: ${{ secrets.FASTLANE_KEYCHAIN_PASSWORD }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} DEVELOPER_DIR: /Applications/Xcode.app @[email protected] brightdigit.com
  43. # .github/workflows/ci.yml — single job for now. # Everything else

    in this workflow is build/test jobs (added in §4–§7) and a # lint job (added in §5–§6). The fastlane job runs after them. name: CI # Run on PRs and on every push to main. The lane picked below depends on # which branch the workflow is running for. on: push: branches: [main] pull_request: branches: [main] jobs: fastlane: runs-on: [self-hosted, macOS] steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v4 with: install: true cache: true - name: Cache RubyGems uses: actions/cache@v5 with: @[email protected]: vendor/ruby brightdigit.com key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} restore-keys: |
  44. - uses: actions/checkout@v6 - uses: jdx/mise-action@v4 with: install: true cache:

    true - name: Cache RubyGems uses: actions/cache@v5 with: path: vendor/ruby key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-gems- name: Setup Ruby run: | ruby --version gem install bundler bundle install - name: Setup Xcode Project run: tuist generate env: DEVELOPER_DIR: /Applications/Xcode.app # `archive` on PRs (build + sign, no upload — catches release-path failures early); # `beta` on main (archive + upload_to_testflight). Both lanes live in the Fastfile. @[email protected] - name: Archive (and upload on main) brightdigit.com run: bundle exec fastlane ${{ github.ref == 'refs/heads/main' && 'beta' || 'archive' }}
  45. uses: actions/cache@v5 with: path: vendor/ruby key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock')

    }} restore-keys: | ${{ runner.os }}-gems- name: Setup Ruby run: | ruby --version gem install bundler bundle install - name: Setup Xcode Project run: tuist generate env: DEVELOPER_DIR: /Applications/Xcode.app # `archive` on PRs (build + sign, no upload — catches release-path failures early); # `beta` on main (archive + upload_to_testflight). Both lanes live in the Fastfile. - name: Archive (and upload on main) run: bundle exec fastlane ${{ github.ref == 'refs/heads/main' && 'beta' || 'archive' }} env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} FASTLANE_KEYCHAIN_PASSWORD: ${{ secrets.FASTLANE_KEYCHAIN_PASSWORD }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} @[email protected] DEVELOPER_DIR: /Applications/Xcode.app brightdigit.com
  46. jobs: test-ios: runs-on: macos-26 steps: - uses: actions/checkout@v6 - name:

    Test on iOS Simulator run: | xcodebuild test \ -scheme MyAppKit-Package \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.2' \ -enableCodeCoverage YES @[email protected] brightdigit.com
  47. jobs: test-spm: runs-on: macos-26 steps: - uses: actions/checkout@v6 - run:

    swift test @[email protected] brightdigit.com test-apple: runs-on: macos-26 strategy: fail-fast: false matrix: include: - destination: 'platform=macOS' xcode: '/Applications/Xcode_26.app' - destination: 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.2' xcode: '/Applications/Xcode_26.app' - destination: 'platform=tvOS Simulator,name=Apple TV,OS=26.2' xcode: '/Applications/Xcode_26.app' - destination: 'platform=watchOS Simulator,name=Apple Watch Ultra 3 (49mm),OS=26.2' xcode: '/Applications/Xcode_26.app' # visionOS pinned to a newer Xcode — newer SDK required - destination: 'platform=visionOS Simulator,name=Apple Vision Pro,OS=26.2' xcode: '/Applications/Xcode_26.1.app' steps: - uses: actions/checkout@v6 - name: Test env: DEVELOPER_DIR: ${{ matrix.xcode }} run: | xcodebuild test \ -scheme MyAppKit-Package \ -destination '${{ matrix.destination }}' \ -enableCodeCoverage YES
  48. run: macOS job: test-spm(swift test) run: iOS Sim job: testapplestrategy.matrix.destination(5

    entries) matrixexpansion run: tvOS Sim run: watchOS Sim run: visionOS Sim @[email protected] brightdigit.com
  49. jobs: lint: runs-on: macos-26 steps: - uses: actions/checkout@v6 - uses:

    jdx/mise-action@v4 with: install: true cache: true - name: Lint run: LINT_MODE=STRICT ./Scripts/lint.sh @[email protected] brightdigit.com
  50. jobs: test-linux: runs-on: ubuntu-latest container: swift:${{ matrix.swift }}-${{ matrix.os }}

    strategy: fail-fast: false matrix: swift: ['6.2', '6.3'] os: [noble, jammy] steps: - uses: actions/checkout@v6 - run: swift test @[email protected] brightdigit.com
  51. jobs: lint: runs-on: macos-26 steps: - uses: actions/checkout@v6 - uses:

    jdx/mise-action@v4 with: install: true cache: true - name: Lint run: LINT_MODE=STRICT ./Scripts/lint.sh @[email protected] brightdigit.com
  52. jobs: lint: runs-on: ubuntu-latest # was macos-26 in lint-macos.yml steps:

    - uses: actions/checkout@v6 - uses: jdx/mise-action@v4 with: install: true cache: true - name: Lint run: LINT_MODE=STRICT ./Scripts/lint.sh @[email protected] brightdigit.com
  53. jobs: test-windows: runs-on: windows-2025 steps: - uses: actions/checkout@v6 - uses:

    compnerd/gha-setup-swift@main with: branch: swift-6.2-release tag: 6.2-RELEASE - uses: actions/cache@v5 with: path: .build key: spm-windows-${{ hashFiles('Package.resolved') }} - name: Fix cache symlinks shell: pwsh run: | # SwiftPM cache stores absolute symlinks that don't survive cache restore on Windows. # Rewrite them to point at the current toolchain. - run: swift test --enable-code-coverage - uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: windows @[email protected] brightdigit.com
  54. jobs: test-android: runs-on: ubuntu-latest strategy: fail-fast: false matrix: android-api-level: [33,

    34] steps: - uses: actions/checkout@v6 # Ubuntu runners ship near-full; reclaim disk before the Android SDK + NDK download. - name: Free disk space uses: jlumbroso/[email protected] with: tool-cache: false android: false dotnet: true haskell: true large-packages: true docker-images: true swap-storage: true - uses: skiptools/swift-android-action@v2 with: android-api-level: ${{ matrix.android-api-level }} android-run-tests: true - uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: android-api${{ matrix.android-api-level }} @[email protected] brightdigit.com
  55. jobs: test-wasm: runs-on: macos-26 steps: - uses: actions/checkout@v6 - name:

    Setup Swift.org Toolchain for Wasm run: | # Download a Swift.org DEVELOPMENT-SNAPSHOT toolchain that ships Wasm SDK support. # Set TOOLCHAINS env so subsequent swift invocations pick it up. - name: Install Wasm SDK run: swift sdk install <wasi-sdk-url> - uses: actions/cache@v5 with: path: .build key: spm-wasm-${{ hashFiles('Package.resolved') }} - run: swift test --triple wasm32-unknown-wasi @[email protected] brightdigit.com
  56. jobs: test-wasm: runs-on: macos-26 steps: - uses: actions/checkout@v6 - name:

    Setup Swift.org Toolchain for Wasm run: | # Download a Swift.org DEVELOPMENT-SNAPSHOT toolchain that ships Wasm SDK support. # Set TOOLCHAINS env so subsequent swift invocations pick it up. - name: Install Wasm SDK run: swift sdk install <wasi-sdk-url> - name: Install wasmtime run: | # Wasm runtime needed to execute the test binary — pin to a known version. curl https://wasmtime.dev/install.sh -sSf | bash -s -- --version v40.0.2 - uses: actions/cache@v5 with: path: .build key: spm-wasm-${{ hashFiles('Package.resolved') }} @[email protected] brightdigit.com # WASI doesn't implement POSIX signals or mmap natively. Any dependency that # touches either (Foundation, swift-log, swift-nio, …) needs these four flags # to link against WASI's emulated-signal / emulated-mman libraries. - run: | swift test \ --triple wasm32-unknown-wasi \ -Xcc -D_WASI_EMULATED_SIGNAL \ -Xcc -D_WASI_EMULATED_MMAN \ -Xlinker -lwasi-emulated-signal \ -Xlinker -lwasi-emulated-mman
  57. ... env: PACKAGE_NAME: MyAppKit jobs: build-ubuntu: runs-on: ubuntu-latest container: swift:${{

    matrix.swift.version }}-${{ matrix.os }} strategy: matrix: os: [noble, jammy] swift: - version: "6.2" type: ["", "wasm"] steps: - uses: actions/checkout@v6 - uses: brightdigit/swift-build@v1 with: type: ${{ matrix.type }} working-directory: Packages/${{ env.PACKAGE_NAME }} build-windows: runs-on: ${{ matrix.runs-on }} strategy: matrix: runs-on: [windows-2022, windows-2025] @[email protected] steps: brightdigit.com - uses: actions/checkout@v6
  58. If OS() @[email protected] brightdigit.com #if os(watchOS) .listStyle(.elliptical) #elseif os(iOS) .listStyle(.insetGrouped)

    #elseif os(macOS) .listStyle(.sidebar) #else .listStyle(.plain) #endif .analyticsScreen(.tapSettings) .navigationTitle(Text("Tap Settings", bund #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif
  59. #if canImport(SwiftUI) import SwiftUI internal struct TabItemLabel: View { internal

    let titleKey: LocalizedStringKey internal let systemImage: String if canImport() } @[email protected] brightdigit.com internal var body: some View { Label { Text(titleKey, bundle: .module) } icon: { Image(systemName: systemImage) } } #Preview { VStack(spacing: 16) { TabItemLabel(titleKey: "Timers", systemI TabItemLabel(titleKey: "Settings", syste } .padding() .background(.background) } #endif
  60. if canImport() Group { #if os(macOS) WelcomeScene() MenuBarScene() Settings(purchaseScreenValue: MarketScene.purchaseScreenValue)

    .windowResizability( .contentSize ) #endif os(macOS) #if (os(iOS) || os(visionOS) || os(macOS)) LibraryScene() #endif #if (os(iOS) || os(macOS)) WishScene() #endif #if canImport(Virtualization) && arch(arm64) MachineScene(system: defaultSystem) #else MachineScene() #endif } @[email protected] brightdigit.com MarketScene() OnboardingScene() FeedbackScene()
  61. Swift Testing and platforms a eb2c77db6 4 846cf13c0f a a

    @[email protected] https://gist.github.com/leogdion/0806c2f41 brightdigit.com
  62. extension Trait where Self == ConditionTrait { internal static var

    iOS: Self { #if os(iOS) .enabled(if: true) #else .enabled(if: false, "iOS only") #endif } internal static var darwin: Self { #if canImport(Darwin) .enabled(if: true) #else .enabled(if: false, "Darwin only") #endif } if canImport() @[email protected] brightdigit.com } internal static var watchConnectivityHost: Sel #if !os(tvOS) && !os(macOS) .enabled(if: true) #else .enabled(if: false, "WatchConnectivity hos #endif }
  63. @Suite("Conformance", .watchConnectivityHost) internal struct Conformance { // MARK: - Functional

    Tests if canImport() @[email protected] brightdigit.com @Test("SessionIntent is a RevisionedMessage exposing its revision") internal func sessionIntentRevisioned() { #if !os(tvOS) && !os(macOS) let intent = SessionIntent(kind: .start, revision: 7) #expect(revision(of: intent) == 7) #else Issue.record("RevisionedMessage conformances are WatchConnectivit #endif }