Kotlin Multiplatformで作成したプライベートリポジトリのSharedをSwift Package Managerで公開する

# はじめに
こんにちは。今日はお仕事で利用しているKotlin Multiplatformについての記事です。
Kotlin MultiplatformはiOS、Android、Webなどの複数環境で同じ機能やViewを提供することができます。業務ロジックを１箇所に集約できるのでOS間差異も減らせると思います。

今回Kotlin MultiplatformをiOSから利用する方法について焦点を当てました。
みなさまご存知の通りcocoaPodsの開発終了が決まっているので、Xcodeから利用する方法を検討する必要があると思います。そこでApple公式のSwift Package Manager（以降SPMとします）でKotlin MultiplatformのSharedにチャレンジしてみました。

# Swift Package Managerで利用するためには
まず利用するためにはリリースバイナリがpublicで公開されている必要があります。
2025/5時点ではprivateリポジトリのreleaseのバイナリは利用できないということです。

解決策としては

1. privateリポジトリのSharedをpublicにする
2. Sharedを公開するためのpublicリポジトリを作成してリリースバイナリを管理する
3. リリースバイナリをpublicなCloud Storageに配置してそこからダウンロードする

個人で利用する分にはSharedをpublicにするでもOKかなと思いますが、お仕事で利用するにはNGかもしれません。
publicなCloud Storage管理でも問題ありませんが、悪意あるユーザからダウンロードを繰り返されると無料枠を使い潰し料金がかさむなどのリスクもあります。
なので、今回は2.の**Sharedを公開するためのpublicリポジトリを作成してリリースバイナリを管理する**をやってみたいと思います。

シーケンス図は以下のような感じです。
```mermaid
sequenceDiagram
  participant Dev as 開発者
  participant PrivateRepo as privateリポジトリ
  participant Actions as GitHub Actions
  participant PublicRepo as publicリポジトリ
  participant Xcode as Xcode(SPM)

  Dev->>PrivateRepo: Shared (KMP) を開発
  Dev->>PrivateRepo: 手動でタグを打つ
  PrivateRepo->>Actions: release開始
  Actions->>PrivateRepo: Package.swiftを生成してpush
  Actions->>PrivateRepo: xcframeworkを生成＆zip化
  Actions->>PrivateRepo: GitHub Release作成 (タグ作成＋zipのアップロード)
  Actions->>PublicRepo: GitHub Release作成 (タグ作成＋zipのアップロード)

  PublicRepo-->>Xcode: SPM経由で1.0.xのリリースを提供
  Xcode->>PublicRepo: SPMで参照追加
  
```

# privateリポジトリのアクションを用意する

今回はreleaseアクションでフレームワークの作成、タグ打ち、バージョンアップ、公開用リポジトリのアクション実行をまとめて行っています。
必要であれば分割してください。

```yml
name: release

on:
  #手動で実行できるようにしておく
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release (e.g., 1.0.0)'
        required: true

# pushが発生するので必要に応じて追加してください
# Githubの「Fine-grained personal access tokens」を利用している場合は必要になるはずです
permissions:
  contents: write

jobs:
  release:
    runs-on: self-hosted # 今回はセルフホストを用意しています
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      # 手動で入力されたタグの入力チェックを行う
      - name: Validate Version Format
        id: validate_version
        run: |
          VERSION="${{ github.event.inputs.version }}"
          echo "Input Version: $VERSION"
          if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$ ]]; then
            echo "GITHUB_ENV_VERSION=$VERSION" >> "$GITHUB_ENV"
          else
            echo "::error::Invalid version format. Expected format: X.Y.Z or X.Y.Z-prerelease"
            exit 1
          fi
      # gradlewを利用してフレームワークの生成を行う
      - name: Build XCFramework via Gradle
        run: ./gradlew :shared:assembleSharedXCFramework
      # できたフレームワークを一時ディレクトリにコピー
      - name: Copy XCFramework to temp folder
        run: |
          mkdir -p ${{ runner.temp }}/spm_release
          cp -r shared/build/XCFrameworks/release/Shared.xcframework ${{ runner.temp }}/spm_release/Shared.xcframework
      # できたフレームワークをZIP化
      - name: Create ZIP file
        run: |
          cd ${{ runner.temp }}/spm_release
          zip -r Shared-${{ env.GITHUB_ENV_VERSION }}.zip Shared.xcframework
          mv Shared-${{ env.GITHUB_ENV_VERSION }}.zip ${{ runner.temp }}/
          echo "ZIP_FILE=Shared-${{ env.GITHUB_ENV_VERSION }}.zip" >> "$GITHUB_ENV"
          cd -
      # Checksum生成
      - name: Generate Checksum
        run: |
          CHECKSUM=$(shasum -a 256 ${{ runner.temp }}/${{ env.ZIP_FILE }} | awk '{print $1}')
          echo "CHECKSUM=$CHECKSUM" >> "$GITHUB_ENV"
      # Package.swiftを再作成
      - name: Re-generate Package.swift
        run: |
          printf '%s\n' \
            '// swift-tools-version:5.3' \
            'import PackageDescription' \
            '' \
            'let package = Package(' \
            '    name: "Shared",' \
            '    platforms: [' \
            '        .iOS(.v14)' \
            '    ],' \
            '    products: [' \
            '        .library(name: "Shared", targets: ["Shared"])' \
            '    ],' \
            '    targets: [' \
            '        .binaryTarget(' \
            '            name: "Shared",' \
            '            url: "https://github.com/{あなたのアカウント}/{あなたの公開用リポジトリの名前}/releases/download/${{ env.GITHUB_ENV_VERSION }}/${{ env.ZIP_FILE }}",' \
            '            checksum: "${{ env.CHECKSUM }}"' \
            '        )' \
            '    ]' \
            ')' \
          > Package.swift
      # Sharedのバージョンアップ（個別にで運用されている場合は省略してください）
      - name: Update cmpVersion in gradle.properties
        run: |
          sed -i '' "s/^cmpVersion=.*/cmpVersion=${{ env.GITHUB_ENV_VERSION }}/" gradle.properties
          cat gradle.properties
      # Package.swiftをコミットしてプッシュする
      - name: Commit and push Package.swift
        run: |
          git config --local user.email "github-actions@github.com"
          git config --local user.name "GitHub Actions Bot"
          git add Package.swift
          git commit -m "Update Package.swift for version ${{ env.GITHUB_ENV_VERSION }}"
          git push origin HEAD
      # リリース時のタグを打つ
      - name: Create Git Tag
        run: |
          git tag -a "${{ env.GITHUB_ENV_VERSION }}" -m "Release ${{ env.GITHUB_ENV_VERSION }}"
          git push origin "${{ env.GITHUB_ENV_VERSION }}"
      # リリースされたタグにzipをアップロードする
      - name: Create GitHub Release and Upload ZIP
        uses: softprops/action-gh-release@v1
        with:
          tag_name: ${{ env.GITHUB_ENV_VERSION }}
          files: ${{ runner.temp }}/${{ env.ZIP_FILE }}
          body: |
            Release for version ${{ env.GITHUB_ENV_VERSION }}
            Checksum (SHA256): ${{ env.CHECKSUM }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      # 公開用リポジトリのアクションを実行する
      - name: Dispatch Public Repository Workflow
        run: |
          curl -X POST \
            -H "Accept: application/vnd.github.v3+json" \
            -H "Authorization: token ${{ secrets.GH_PUBLIC_REPO_DISPATCH_TOKEN }}" \
            https://api.github.com/repos/{あなたのアカウント名や組織名}/{公開するリポジトリ名}/actions/workflows/publish_binaries.yml/dispatches \
            -d '{
              "ref": "main",
              "inputs": {
                "version": "'${{ env.GITHUB_ENV_VERSION }}'"
              }
            }'
            
```
| シークレットキー | 値 |
| --- | --- |
| GH_PUBLIC_REPO_DISPATCH_TOKEN | publicリポジトリにアクセスするためのGithubのPAT（パーソナルアクセストークン） |

#### 確認ポイント
- ここで作成したPackage.swiftのzipのurlは公開用のリポジトリのリリースを向いている必要があります（privateと間違えないように）
- アクションが成功した後に手動でPackage.swiftのzipのurlをブラウザに直接叩いてダウンロード確認できます
- zipの中身もあるか展開して確認しましょう


# 公開用のリポジトリを作成する

公開用に使うだけですので空のリポジトリを作成し、Github Actionsのワークフローだけ準備します。

```yml
name: publish_binaries

on:
  # 手動で実行できるようにしておきます
  workflow_dispatch:
    inputs:
      version:
        description: 'Version of the release'
        required: true

jobs:
  publish:
    runs-on: self-hosted # 今回はセルフホストランナーを利用しています。
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      # privateリポジトリからZipをダウンロードしてreleaseに利用します
      - name: Download ZIP from private release (via GitHub API)
        run: |
          set -e
          OWNER="${{ secrets.PRIVATE_REPO_OWNER }}"
          REPO="${{ secrets.PRIVATE_REPO_NAME }}"
          VERSION="${{ github.event.inputs.version }}"
          ZIP_NAME="Shared-${VERSION}.zip"
          API_URL="https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${VERSION}"
          echo "Fetching release info for tag: $VERSION"
          response=$(curl -s -H "Authorization: token ${{ secrets.REPO_READ_TOKEN }}" "$API_URL")
          # Extract asset download URL
          download_url=$(echo "$response" | jq -r ".assets[] | select(.name == \"$ZIP_NAME\") | .url")
          if [ -z "$download_url" ] || [ "$download_url" == "null" ]; then
            echo "::error::Asset $ZIP_NAME not found in release $VERSION"
            exit 1
          fi
          echo "Downloading from $download_url"
          curl -L \
            -H "Authorization: token ${{ secrets.REPO_READ_TOKEN }}" \
            -H "Accept: application/octet-stream" \
            "$download_url" -o "$ZIP_NAME"
          echo "ZIP_FILE=$ZIP_NAME" >> "$GITHUB_ENV"
      # Checksumを生成します
      - name: Generate Checksum
        run: |
          CHECKSUM=$(shasum -a 256 ${{ env.ZIP_FILE }} | awk '{print $1}')
          echo "CHECKSUM=$CHECKSUM" >> "$GITHUB_ENV"
      # Releaseを作成します
      - name: Create GitHub Release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.REPO_READ_TOKEN }}
        with:
          tag_name: ${{ github.event.inputs.version }}
          release_name: Release ${{ github.event.inputs.version }}
          body: |
            privateリポジトリから公開されたバイナリです。
            Checksum (SHA256): ${{ env.CHECKSUM }}
          draft: false
          prerelease: false
      # 作成したReleaseにzipをアップロードします
      - name: Upload ZIP to Release
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.REPO_READ_TOKEN }}
        with:
          upload_url: ${{ steps.create_release.outputs.upload_url }}
          asset_path: ${{ env.ZIP_FILE }}
          asset_name: ${{ env.ZIP_FILE }}
          asset_content_type: application/zip
```

| シークレットキー | 値 |
| --- | --- |
| PRIVATE_REPO_OWNER | privateリポジトリのアカウント名 |
| PRIVATE_REPO_NAME | privateリポジトリの名前 |
| REPO_READ_TOKEN | privateリポジトリにアクセスするためのGithubのPAT（パーソナルアクセストークン） |

# リポジトリのGitHubのアクション権限確認
実行する前にGitHubの権限を確認しましょう。
これはお使いのGithubの状況によって変わりますので管理者に適切な権限付与を依頼してください。

リポジトリでGitHubのアクションを実行するので適切に設定をしておく必要があります。
色々実験した結果Allow all actions and reusable workflowsにしておけば問題なかったです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/bf8cf65e-0903-4f65-aef9-ebc76709e077.png)

# Fine-grained personal access tokensの設定
Fine-grained personal access tokensではリポジトリ毎に細かく権限を設定する必要があります。
Personal access tokens (classic)を利用している場合はリポジトリ毎のアクセス制限はないのでこの手順はスキップしていただいて大丈夫なはずです。

私の場合は２つとも個人組織リポジトリを利用しているので２つのリポジトリを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/53d9d3cd-9b8c-4992-8d08-bd850352ab3f.png)

Repository permissionsは３つ必要なので設定します。

Actions: read
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/c25a5986-3a91-4ff8-a7be-f59d89cc2344.png)
contents: write
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/bfb14b2d-d0de-43ad-aecc-c435cf4f95b6.png)
Workflows: read
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/ba4dc761-0854-4319-a844-2be34c73e15f.png)

# いざ実行
それでは早速実行してみましょう。
privateリポジトリでrelease.ymlを実行します。
引数に必要なversionを入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/b1212e9f-e3a3-48aa-9e2e-77f94e06a96c.png)
privateリポジトリのリリースが作成されていればOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/0494025a-2215-442f-b130-fe8689ddcb34.png)

release.ymlが完了するとpublicリポジトリのアクションが実行されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/4b9570e6-a26d-4ae1-9465-41efca9d7a91.png)

同様にpublicリポジトリのリリースが作成されていればOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/f4e8c69e-9e34-43c3-9c11-36ffb0ee09f9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/1235b4de-1564-4ab4-bde0-38fb3148f544.png)

# Xcodeからパッケージ追加
メニュー>File>Add Package Dependencies
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/879344ae-0b17-4bb8-b0c8-023c36748021.png)

右上の検索バーにgithubのurlを入力し、右下の[Add Package]ボタンをタップします。
（publicなリポジトリなのでhttp形式・ssh形式どちらでもOKです）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/3cad85f3-086c-45d5-a28b-787b24a0c421.png)

成功するとツリーにパッケージが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/2261e779-8b8c-49ac-983b-892d68d0b432.png)

これで利用できるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/2bf7b13a-6da8-4004-87a4-704ae6c4143b.png)

# 終わりに
途中GithubActionの連動やアクセス権限の問題など苦労しましたが、AIご協力のもと無事に完成しました。他にもいい方法あるかもしれませんが、同じようなことを考えていらっしゃる方の参考になれば幸いです。


# 参考
- https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-spm-export.html