CI/CD for KMP with GitHub Actions

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A Kotlin Multiplatform (KMP) project is built by several toolchains at once: the JVM and Android build tools, the Kotlin/Native compiler, Node.js for Kotlin/Wasm, and Xcode for everything Apple. Continuous integration (CI) is where that matrix stops being a local accident and becomes a contract: every pull request is compiled and tested on every target before it can merge, and every release is produced by a clean machine rather than by one developer’s laptop. GitHub Actions is the natural fit for a repository already hosted on GitHub; if you need a refresher on branches, pull requests and tags, see Git and GitHub.

This page targets the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 with com.android.kotlin.multiplatform.library, and Xcode 26.4, which is the Xcode version Kotlin 2.4.20 is tested against. The actions used are actions/checkout@v7, actions/setup-java@v6, gradle/actions/setup-gradle@v6, actions/cache@v6, actions/upload-artifact@v7 and maxim-lobanov/setup-xcode@v1. Stability is stated where it is not Stable: Kotlin/Wasm and Compose Multiplatform for web are Beta, and Swift export and SwiftPM import are Alpha, so a CI pipeline that depends on them should treat their failures as informative rather than blocking when you adopt them early. The examples slice the running example, TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It, and its small library com.example:trail-geo. Android-only pipelines (emulators, Sonar, Play tracks) are covered in Android: CI/CD with GitHub Actions and are linked rather than repeated; Xcode-centred pipelines are covered in Apple: CI/CD with Xcode Cloud and the Command Line.

The Shape of a KMP Pipeline

The defining constraint is that Apple targets compile only on macOS. The Kotlin/Native compiler can cross-compile to Linux and Windows from other hosts, but it needs Xcode’s SDKs and linker to produce iOS, macOS, watchOS and tvOS binaries. Conversely, macOS runners are slower to start and more expensive than Linux runners. A good pipeline therefore splits the work by host:

Host Runs Why there

ubuntu-latest

JVM and desktop tests (jvmTest), Android host tests (testAndroidHostTest), Wasm tests (wasmJsTest), lint, ABI check, Dokka, and the non-Apple compilation of commonMain

Fast, cheap, cached well; no Apple SDK needed

macos-latest

iosSimulatorArm64Test, linking the iOS framework, the xcodebuild simulator build of iosApp, the XCFramework for releases

The only place Apple targets compile; the only place the iOS simulator runs

The cost trade-off is how often the slow job runs. A common policy is to run the Linux job on every push and the macOS job on every pull request (and on merge to the default branch), so iOS regressions are caught before merge without paying macOS minutes for each work-in-progress commit.

flowchart LR PR["Pull request"] --> LIN["Linux job
JVM, Android host, Wasm tests"] PR --> MAC["macOS job
iOS tests, xcodebuild"] LIN --> MERGE["Merge to main"] MAC --> MERGE MERGE --> REL["GitHub release
(tag)"] REL --> MC["Maven Central
(trail-geo)"] REL --> SPM["SwiftPM
(XCFramework + checksum)"] REL --> STORES["Google Play and
TestFlight"]

Pinning Tool Versions

Reproducibility is the first requirement of CI, and three runner-provided tools drift silently: the JDK, Gradle and Xcode. Pin each one explicitly.

Tool How it is pinned What breaks if you do not

JDK

actions/setup-java@v6 with distribution and java-version

Gradle toolchain mismatches; AGP 9 needs a recent JDK

Gradle

The wrapper (gradlew) plus gradle/actions/setup-gradle@v6, which validates the wrapper and caches

A different Gradle than local builds, outside the range tested with Kotlin 2.4.20

Xcode

maxim-lobanov/setup-xcode@v1 with xcode-version: '26.4'

macos-latest moves forward (it ships macOS 26 with a newer default Xcode); a newer Xcode than the Kotlin version supports produces linker or interop failures that appear without any change in your repository

Kotlin’s compatibility guide lists the Xcode version each Kotlin release is tested against, and the runner image list shows which Xcode versions exist on which image. When you upgrade Kotlin, upgrade the pinned Xcode in the same pull request.

The CI Workflow

The workflow below is the heart of the pipeline. It has two jobs, one per host, a concurrency group that cancels superseded runs, and a matrix on the macOS job to build both the library tests and the app.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

# Cancel an older run of the same branch or pull request when a new commit arrives.
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  linux:
    name: Linux (JVM, Android host, Wasm)
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'

      - uses: gradle/actions/setup-gradle@v6

      # Cache the Kotlin/Native toolchain so it is downloaded once, not on every run.
      - uses: actions/cache@v6
        with:
          path: ~/.konan
          key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
          restore-keys: konan-${{ runner.os }}-

      - name: Test JVM, Android host and Wasm
        run: ./gradlew jvmTest testAndroidHostTest wasmJsTest --continue

      - name: Check public API (ABI) and build docs
        run: ./gradlew checkKotlinAbi dokkaGenerate

      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: linux-test-reports
          path: |
            **/build/reports/tests/
            **/build/test-results/
          if-no-files-found: ignore

Each piece earns its place:

  • concurrency with cancel-in-progress stops a stale run when you push again. On macOS minutes this is the single biggest saving, because an iOS job is the slowest in the workflow.

  • permissions: contents: read gives the automatic GITHUB_TOKEN the least access that tests need; jobs that must publish raise it locally.

  • setup-gradle@v6 configures the Gradle user home cache (dependencies, wrapper and build-cache entries) and produces a build summary on the run page. It replaces hand-written actions/cache steps for ~/.gradle.

  • ~/.konan holds the downloaded Kotlin/Native compiler distribution, LLVM bits and dependency libraries; it is several hundred megabytes and is not covered by the Gradle cache, so cache it explicitly. Key it by the files that decide the Kotlin version, so a Kotlin upgrade creates a fresh entry.

  • --continue lets Gradle run every test task and report all failures at once, instead of stopping at the first failing target.

  • if: always() on the upload step is what makes reports available exactly when something failed.

The allTests aggregate task runs the tests of every target the current host can build; on Linux it skips Apple targets, which is why naming the host’s tasks explicitly (jvmTest testAndroidHostTest wasmJsTest) is clearer in a Linux job. The task names are explained in Testing Multiplatform Code.

The macOS Job and the iOS Simulator Build

The macOS job pins Xcode, runs the Apple tests on the iOS simulator, and then builds the real iosApp Xcode project for the simulator with xcodebuild. This second step is the one that catches errors a Kotlin-only build cannot: a mis-wired Run Script build phase, a missing framework search path, a Swift API that changed after the Kotlin framework was regenerated.

# .github/workflows/ci.yml (continued)
  macos:
    name: macOS (${{ matrix.task.name }})
    runs-on: macos-latest
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix:
        task:
          - name: iOS shared tests
            gradle: ':sharedLogic:iosSimulatorArm64Test'
          - name: iOS app build
            gradle: ':sharedLogic:linkDebugFrameworkIosSimulatorArm64'
    steps:
      - uses: actions/checkout@v7

      # Pin Xcode: macos-latest moves to newer images, Kotlin 2.4.20 is tested with Xcode 26.4.
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '26.4'

      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'

      - uses: gradle/actions/setup-gradle@v6

      - uses: actions/cache@v6
        with:
          path: ~/.konan
          key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
          restore-keys: konan-${{ runner.os }}-

      - name: Run Gradle task
        run: ./gradlew ${{ matrix.task.gradle }}

      - name: Build iosApp for the simulator
        if: matrix.task.name == 'iOS app build'
        run: |
          xcodebuild build \
            -project iosApp/iosApp.xcodeproj \
            -scheme iosApp \
            -configuration Debug \
            -destination 'platform=iOS Simulator,name=iPhone 17' \
            CODE_SIGNING_ALLOWED=NO

      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: macos-reports-${{ strategy.job-index }}
          path: |
            **/build/reports/
            **/build/test-results/
          if-no-files-found: ignore

Points worth understanding:

  • The matrix turns one job definition into several parallel jobs, each with its own runner. Here it separates fast shared tests from the slower app build so a failure names the stage. fail-fast: false lets the other entries finish, which gives a full picture rather than one red cross.

  • CODE_SIGNING_ALLOWED=NO builds without certificates. CI for pull requests needs no signing identity, which is also a security property: untrusted pull-request code never sees signing material.

  • The destination must name a simulator that exists on the pinned Xcode. List the available devices on the runner with xcrun simctl list devices available and keep the name in sync with the Xcode pin.

  • Gradle runs from the Xcode build phase too. The iosApp project calls ./gradlew :sharedLogic:embedAndSignAppleFrameworkForXcode during the build; the project settings in iosApp/Configuration/Config.xcconfig decide the framework flavor. See iOS Integration Options: Framework, SwiftPM, CocoaPods and Swift Export.

A tiny Swift test target is part of the same story: if iosApp has an XCTest bundle that calls the shared framework, run it with xcodebuild test instead of build.

// iosApp/iosAppTests/PlatformNameTests.swift
import XCTest
import sharedLogic

final class PlatformNameTests: XCTestCase {
    func testPlatformNameMentionsIOS() {
        // platformName() is the expect/actual function from sharedLogic.
        XCTAssertTrue(PlatformKt.platformName().contains("iOS"))
    }
}

Speeding Up and Stabilizing Builds

CI time is dominated by three costs: downloading toolchains, compiling Kotlin/Native, and starting simulators.

Technique Effect

Cache ~/.konan

Avoids re-downloading the Kotlin/Native toolchain; the largest single win on a cold runner

setup-gradle build cache and read-only mode on pull requests

Dependency and task outputs are reused; only the default branch writes the cache, so a pull request cannot poison it

Build only the host’s targets

Declare Apple targets conditionally or run host-specific tasks (see below), so Linux does not try to configure what it cannot build

Configuration cache

org.gradle.configuration-cache=true in gradle.properties skips repeated configuration

timeout-minutes

A hung simulator costs minutes of billing; a timeout converts it into a visible failure

Kotlin automatically disables Apple targets on non-macOS hosts with a warning. To silence the warning in CI logs, set kotlin.native.ignoreDisabledTargets=true in gradle.properties, as in:

# gradle.properties
org.gradle.configuration-cache=true
org.gradle.caching=true
kotlin.native.ignoreDisabledTargets=true

Publishing a Library: the Release Workflow

A library such as com.example:trail-geo is released when a maintainer publishes a GitHub release. The trigger release: types: [released] fires when a release goes from draft or pre-release to a normal release, which is the moment you want a public artifact; created would also fire for drafts. The workflow runs on macOS because a single macOS host is the only machine that can produce every publication (JVM, Android, Apple, JS, Wasm) in one pass; the vanniktech plugin 0.37.0 then uploads them to the Central Portal and the workflow builds the XCFramework for Swift users.

# .github/workflows/release.yml
name: Release trail-geo

on:
  release:
    types: [released]

permissions:
  contents: write   # needed to attach files to the release

jobs:
  publish:
    runs-on: macos-latest
    timeout-minutes: 90
    environment: maven-central   # lets you require reviewers and scope secrets
    steps:
      - uses: actions/checkout@v7

      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '26.4'

      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'

      - uses: gradle/actions/setup-gradle@v6

      - name: Publish to Maven Central
        run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
        env:
          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }}
          ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }}
          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}

The magic is in the naming. Gradle treats any environment variable (or -P property) beginning with ORG_GRADLE_PROJECT_ as a project property, so ORG_GRADLE_PROJECT_signingInMemoryKey becomes the property signingInMemoryKey that the plugin reads. The secrets never appear in gradle.properties or in the repository. The build script that consumes them is short, and keeps the signing decision out of local builds:

// sharedLogic/build.gradle.kts (trail-geo library module)
import com.vanniktech.maven.publish.SonatypeHost

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.android.kotlin.multiplatform.library)
    alias(libs.plugins.vanniktech.maven.publish) // com.vanniktech.maven.publish 0.37.0
}

mavenPublishing {
    // Credentials come from ORG_GRADLE_PROJECT_mavenCentral* in CI, or ~/.gradle/gradle.properties locally.
    publishToMavenCentral()
    if (providers.gradleProperty("signingInMemoryKey").isPresent) {
        signAllPublications()
    }
    coordinates("com.example", "trail-geo", version.toString())
}

See vanniktech — Publishing to Maven Central. The full publishing story (namespace, GPG, POM metadata) is in Publishing Multiplatform Libraries.

Attaching an XCFramework and its Checksum

Swift users consume a KMP library through Swift Package Manager (SwiftPM). A binary target in Package.swift points at a zip of an XCFramework and a checksum that SwiftPM verifies before linking. The release workflow therefore produces both and uploads them to the same release: the zip as a downloadable asset, the checksum as text that goes into Package.swift. The XCFramework is assembled by a Gradle task that the Kotlin plugin registers when you declare XCFramework("TrailGeo").

// sharedLogic/build.gradle.kts (continued)
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

kotlin {
    val xcf = XCFramework("TrailGeo")
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "TrailGeo"
            xcf.add(this)
        }
    }
}

The additional steps, appended to the publish job, build the zip, compute the checksum with SwiftPM itself, and attach both files to the release that triggered the run:

# .github/workflows/release.yml (continued, steps of the publish job)
      - name: Assemble the XCFramework
        run: ./gradlew :sharedLogic:assembleTrailGeoReleaseXCFramework

      - name: Zip it and compute the checksum
        id: xcf
        run: |
          cd sharedLogic/build/XCFrameworks/release
          zip -r TrailGeo.xcframework.zip TrailGeo.xcframework
          CHECKSUM=$(swift package compute-checksum TrailGeo.xcframework.zip)
          echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT"
          echo "$CHECKSUM" > TrailGeo.xcframework.zip.sha256

      - name: Attach to the release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release upload "${{ github.event.release.tag_name }}" \
            sharedLogic/build/XCFrameworks/release/TrailGeo.xcframework.zip \
            sharedLogic/build/XCFrameworks/release/TrailGeo.xcframework.zip.sha256 \
            --clobber

The consumer’s manifest is then updated to the new URL and checksum, usually by the same workflow or by a follow-up pull request:

// Package.swift (trail-geo repository root)
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "TrailGeo",
    platforms: [.iOS(.v15)],
    products: [.library(name: "TrailGeo", targets: ["TrailGeo"])],
    targets: [
        .binaryTarget(
            name: "TrailGeo",
            url: "https://github.com/example/trail-geo/releases/download/1.2.0/TrailGeo.xcframework.zip",
            checksum: "<checksum printed by swift package compute-checksum>"
        )
    ]
)

Common mistakes: publishing the checksum of a different zip than the one uploaded (always zip once and compute from that exact file), forgetting --clobber on a re-run so the upload fails, and leaving Package.swift pointing at a version whose release asset does not exist yet.

Delivering Apps

Library publishing ends at a registry; app delivery ends at a store. The KMP part of an app pipeline is thin, because androidApp is an ordinary Android application module and iosApp is an ordinary Xcode project; the shared code is just one more dependency. What changes is the host split from earlier: Android delivery can run on Linux, iOS delivery must run on macOS.

Android: Upload to Google Play

Build a signed Android App Bundle from androidApp and upload it to a Play track. The keystore decoding, the service-account JSON and the track-promotion strategy are identical to a pure Android project, so they are described once in Android: CI/CD with GitHub Actions, with the surrounding concepts in Building, Signing and Releasing and Publishing on Google Play. In a KMP repository the only difference is the Gradle path of the app module:

# .github/workflows/release-android.yml
name: Release Android app
on:
  push:
    tags: ['app-v*']
jobs:
  play:
    runs-on: ubuntu-latest
    environment: play-store
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'
      - uses: gradle/actions/setup-gradle@v6
      - name: Build the signed bundle
        run: ./gradlew :androidApp:bundleRelease
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
      - name: Upload to the internal track
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.trailmate
          releaseFiles: androidApp/build/outputs/bundle/release/androidApp-release.aab
          track: internal

iOS: TestFlight with fastlane

TestFlight delivery needs a macOS runner, a distribution certificate, a provisioning profile and an App Store Connect API key. The signing concepts are covered in Signing, Capabilities and Configuration and the review process in App Store Connect, TestFlight and Review; fastlane wraps archive, signing and upload into a single lane that CI can call.

# iosApp/fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Build and upload TrailMate to TestFlight"
  lane :beta do
    api_key = app_store_connect_api_key(
      key_id: ENV["ASC_KEY_ID"],
      issuer_id: ENV["ASC_ISSUER_ID"],
      key_content: ENV["ASC_KEY_CONTENT"],
      is_key_content_base64: true
    )
    # match fetches signing material from a private, encrypted store.
    match(type: "appstore", readonly: true, api_key: api_key)
    build_app(scheme: "iosApp", export_method: "app-store")
    upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
  end
end

The workflow installs Xcode, lets fastlane do the signing, and passes the key as a secret:

# .github/workflows/release-ios.yml
name: Release iOS app
on:
  push:
    tags: ['app-v*']
jobs:
  testflight:
    runs-on: macos-latest
    environment: app-store
    steps:
      - uses: actions/checkout@v7
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '26.4'
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'
      - uses: gradle/actions/setup-gradle@v6
      - name: Upload to TestFlight
        working-directory: iosApp
        run: bundle exec fastlane beta
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}

Two KMP-specific points apply before archiving. The Kotlin framework is built by the Xcode build phase, so the Java setup step is required on the macOS runner even though no Kotlin appears in the workflow. And App Store submission now expects a privacy manifest for the app and for frameworks it embeds; see Publishing KMP Apps for the checklist, including dSYM upload for symbolicating Kotlin frames.

Alternatives: TeamCity Cloud and Xcode Cloud

If you prefer not to own signing in YAML, the Kotlin Multiplatform IDE plugin can generate a TeamCity Cloud configuration for an iOS pipeline, and Xcode Cloud (see Apple: CI/CD with Xcode Cloud and the Command Line) can run the iosApp workflow from App Store Connect. Both are mentioned here for completeness; GitHub Actions remains the common denominator when the rest of the pipeline already lives there.

Dependabot

A KMP repository has three dependency ecosystems: Gradle (the version catalog), GitHub Actions (the pinned action versions in this page) and Swift Package Manager or Bundler for the Xcode side. Dependabot opens pull requests for all of them, and the CI workflow above validates each one.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: gradle
    directory: /
    schedule:
      interval: weekly
    groups:
      kotlin-and-compose:
        patterns: ["org.jetbrains.kotlin*", "org.jetbrains.compose*"]
    open-pull-requests-limit: 10

  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

  - package-ecosystem: bundler
    directory: /iosApp
    schedule:
      interval: monthly

Grouping Kotlin and Compose updates matters because they must move together: the Compose compiler plugin follows the Kotlin version, and Compose Multiplatform declares a Kotlin range. Bumping them in separate pull requests produces a red build for the first one for no good reason. Dependabot does not know about Xcode, so the Xcode pin is bumped by hand in the same pull request as a Kotlin upgrade.

Secrets Hygiene

Release pipelines hold the most valuable credentials in the project: the Maven Central token, the GPG signing key, the Android upload keystore and the App Store Connect API key. Treat the workflow as part of the attack surface.

Practice How it looks in these workflows

Placeholders in the repository, secrets in GitHub

Only secrets.NAME expressions appear in YAML; no value, not even a test key, is committed

Least privilege for GITHUB_TOKEN

Top-level permissions: contents: read; only the release job raises it to contents: write

Environments with required reviewers

environment: maven-central, play-store and app-store hold the publishing secrets, so a release needs an approval and the secrets are invisible to ordinary pull-request jobs

No secrets on untrusted code

Pull requests from forks do not receive secrets; never use pull_request_target to build their code

Short-lived credentials where possible

Prefer OpenID Connect for cloud providers and scoped API keys over personal passwords

Pin or review third-party actions

Dependabot updates action versions; for high-risk actions, pin to a commit SHA

No secrets in logs

Avoid set -x around secret use; GitHub masks registered secrets but not values you derive from them

Rotation

Rotate the signing key, tokens and API keys on a schedule and after any leak suspicion

Pair the repository settings with a scanner on every push so an accidentally committed token is caught before it reaches the default branch. The same workflow style can also run the library’s ABI check (checkKotlinAbi), which is described in Publishing Multiplatform Libraries.

Common Mistakes

  • Leaving Xcode on the runner default. The first image update turns a green pipeline red. Pin it with setup-xcode and bump it together with Kotlin.

  • Running everything on macOS. It is slower and costs more; keep JVM, Android and Wasm checks on Linux.

  • Not caching ~/.konan. Every run re-downloads the Kotlin/Native toolchain.

  • Forgetting if: always() on report uploads. The reports vanish exactly when you need them.

  • Skipping the xcodebuild step. A Gradle-only macOS job misses breakage in the Xcode project and in the Swift consumption of the framework.

  • Releasing on created instead of released. Drafts and pre-releases would publish public artifacts.

  • Computing the checksum from a different zip than the uploaded one. SwiftPM then refuses the package.

  • Committing signing keys or tokens. Use environment secrets and ORG_GRADLE_PROJECT_* variables.

  • Treating Beta targets as blocking too early. Run Wasm in a separate, non-blocking job until it is Stable.