Swift Package Manager の Plugin で使用する自前 executableTarget を artifact bundle に置き換える

はじめに

こんにちは。久しぶりに技術ブログを書きます。(このブログでは初めてかも)
今回は artifact bundle を触ってみたので、そちらについて記載していこうと思います。
また、こちらは FOLIO Advent Calendar 2024 の3日目の記事になります。

前提

Swift Package Manager のビルドプラグインでビルド時に処理をすることがあるかと思います。 今回は、このプラグインの処理に自前の executableTarget を利用するというユースケースを想定します。

例えば、下記のようなイメージです。

↓ パッケージマニフェスト

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyApp",
    platforms: [
        .iOS(.v17),
    ],
    dependencies: [
        // 略
        ...
        // ⭐️ MyExecutable のためのライブラリ
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
        .package(url: "https://github.com/some/tools", from: "1.0.0"),
    ],
    targets: [
        .target(
            name: "MyApp",
            plugins: [
                .plugin(name: "MyPlugin"), // ⭐️ MyPlugin を適用
            ]),
        .plugin(
            name: "MyPlugin",
            capability: .buildTool(),
            dependencies: [
                "MyExecutable", // ⭐️ MyExecutable を使用
            ]),
        .executableTarget(
            name: "MyExecutable",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "SomeTools", package: "tools"),
            ]),
    ]
)

プラグインの実装

import Foundation
import PackagePlugin

@main
struct MyPlugin: BuildToolPlugin {
    func createBuildCommands(context: PackagePlugin.PluginContext,
                             target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] {
 
        // do something ⚒️
        
        return [
            .buildCommand(
                displayName: "Run MyPlugin",
                executable: try context.tool(named: "MyExecutable").url, // ⭐️ MyExecutable を使用
                arguments: [],
                outputFiles: [])
        ]
    }
}

課題感

さて、上記のような前提で MyApp のビルドを行うと、実行するプラグイン(MyPlugin)が依存する executableTarget (MyExecutable) が依存しているライブラリもビルド時にコンパイルする必要があります。
こういったプラグインで利用するような自前の executableTarget はあまりアップデートする機会も多くはないのではないでしょうか?
ほとんどアップデートされないようなものであれば、依存するライブラリによっては都度都度ビルドするとビルド時間が長くなってしまうデメリットが出てくるかもしれません。

artifact bundle を作ってみる

上記の解決方法の一つとして、 artifact bundle を用いてみます。
ざっくり言うと、 executableTarget をビルド済みのバイナリにして binaryTarget として使用するというアプローチになります。

前述の例を元にこのアプローチを説明していきます。

そもそも artifact bundle って?

詳しくは、 SE-0305: Package Manager Binary Target Improvements を参照いただければと思いますが、今回のユースケースから見ると、

ビルド済みのバイナリファイルをプラグインから利用できるようにするためのもの

とも見れます。(随分強引な解釈ではあります) *1

方針

さて、 executableTarget も結局はビルドされて実行ファイルになるので、この実行ファイルを artifact bundle として binaryTarget で扱うことができれば、プラグインの実行のために都度ビルドする必要もなくなります。
作成した artifact bundle はリモート管理することも可能ですが、今回はローカルに持つ方法で進めてみます。

1. executableTarget を別パッケージにする

artifact bundle にする executableTarget を別のパッケージにしてしまいます。
元のパッケージから、 executableTarget が依存するライブラリを切り離すことができるためです。

今回は artifact bundle をローカルで持つ方針なので下記のような形にしていきますが、 artifact bundle をリモート管理する場合はそもそも別リポジトリにするというのもありかと思います。

# before

.
├── Package.swift
├── Plugins
│   └── MyPlugin.swift
└── Sources
    ├── MyApp
    │   └── MyApp.swift
    └── MyExecutable
        └── MyExecutable.swift

# after

.
├── MyExecutable
│   ├── Package.swift
│   └── Sources
│       └── MyExecutable
│           └── MyExecutable.swift
├── Package.swift
├── Plugins
│   └── MyPlugin.swift
└── Sources
    └── MyApp
        └── MyApp.swift

この時点で MyApp の Package.swift に記載していた MyExecutable 用の dependencies が剥がれることになります。

Package.swift

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyApp",
    platforms: [
        .iOS(.v17),
    ],
    dependencies: [
        // 略
        ...
-       // ⭐️ MyExecutable のためのライブラリ
-       .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
-       .package(url: "https://github.com/some/tools", from: "1.0.0"),
+       // artifact bundle にした後は依存しなくなるので、後ほど削除する
+       .package(path: "MyExecutable"),
    ],
    targets: [
        .target(
            name: "MyApp",
            plugins: [
                .plugin(name: "MyPlugin"),
            ]),
        .plugin(
            name: "MyPlugin",
            capability: .buildTool(),
            dependencies: [
                "MyExecutable",
            ]),
-       .executableTarget(
-           name: "MyExecutable",
-           dependencies: [
-               .product(name: "ArgumentParser", package: "swift-argument-parser"),
-               .product(name: "SomeTools", package: "tools"),
-           ]),
    ]
)

MyExecutable/Package.swift

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyExecutable",
    products: [
        // ⭐️ artifact bundle にしたら外部から参照しなくなるので不要になる
        .executable(
            name: "MyExecutable",
            targets: ["MyExecutable"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
        .package(url: "https://github.com/some/tools", from: "1.0.0"),
    ],
    targets: [
        .executableTarget(
            name: "MyExecutable",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "SomeTools", package: "tools"),
            ]),
    ]
)

2. artifact bundle を作る

実際に MyExecutable の artifact bundle を作っていきます。

artifact bundle 用のディレクトリを作成する

SE-0305 #Artifact bundle にも記載の通り、 artifact bundle は下記のような構造になっている必要があります。

<name>.artifactbundle
 ├ info.json
 ├ <artifact>
 │  ├ <variant>
 │  │  ├ <executable>
 │  │  └ <other files>
 │  └ <variant>
 │     ├ <executable>
 │     └ <other files><artifact>
 │  └ <variant>
 │     ├ <executable>
 │     └ <other files><artifact>
 ┆  └┄

今回は artifact は 1つなので下記のような形にしていきます。

MyExecutable.artifactbundle
 ├ info.json
 └ MyExecutable-1.0.0-macos
    └ bin
       └ MyExecutable

1.0.0 は MyExecutable のバージョンを指しますが、バージョニングが不要であれば無しでも良いのかもしれません。
info.json は後述するので、まずはディレクトリを作っていきます。 今回は <Project Root>/MyExecutable 直下(MyExecutable パッケージ直下)に作っていきます。

cd MyExecutable
mkdir -p MyExecutable.artifactbundle/MyExecutable-1.0.0-macos/bin

info.json を作成する

artifact bundle には info.json というマニフェストファイルが必要になります。
前工程で作成したディレクトリの中に格納する必要があるので作成していきます。

MyExecutable.artifactbundle
 ├ info.json # ⭐️ ここ
 └ MyExecutable-1.0.0-macos
    └ bin
       └ MyExecutable

info.jsonスキーマSE-0305 #Artifact bundle manifest にて下記のように定義されています。

{
    "schemaVersion": "1.0",
    "artifacts": {
        "<identifier>": {
            "version": "<version number>",
            "type": "executable",
            "variants": [
                {
                    "path": "<relative-path-to-executable>",
                    "supportedTriples": [ "<triple1>", ... ]
                },
                ...
            ]
        },
        ...
    }
}

今回は下記のようにします。

{
    "schemaVersion": "1.0",
    "artifacts": {
        "MyExecutable": {
            "version": "1.0.0",
            "type": "executable",
            "variants": [
                {
                    "path": "MyExecutable-1.0.0-macos/bin/MyExecutable",
                    "supportedTriples": [
                        "x86_64-apple-macosx",
                        "arm64-apple-macosx"
                    ]
                }
            ]
        }
    }
}
  • artifacts.MyExecutable.variants[0].path
    • バイナリファイルへの相対パスになります。 MyExecutable-1.0.0-macos/bin ディレクトリは前工程で作成したディレクトリで、そこに後工程で生成するバイナリファイル(MyExecutable)を配置する想定なのでこのようにしています。
  • artifacts.MyExecutable.variants[0].supportedTriples
    • サポートするアーキテクチャを記載します。ここは実装や要件に合わせて適宜変更してください。

MyExecutable のバイナリを生成する

次に MyExecutable のバイナリを生成して artifact bundle のディレクトリに配置していきます。

MyExecutable.artifactbundle
 ├ info.json
 └ MyExecutable-1.0.0-macos
    └ bin
       └ MyExecutable # ⭐️ ここ
swift build -c release
cp "$(swift build -c release --show-bin-path)/MyExecutable" MyExecutable.artifactbundle/MyExecutable-1.0.0-macos/bin

これで artifact bundle の構造は一通り完成となります。

(Optional) zip に固める

XCFramework 同様、リモートで管理する場合は zip に固める必要があります。
一方、今回のようにローカルで持つ場合は zip にする必要はないようなので、今回は zip で固めずにディレクトリのまま進めてみます。

3. 作成した artifact bundle を使用する

それでは、大元の Package.swift から作成した artifact bundle を参照してみます。

Package.swift

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyApp",
    platforms: [
        .iOS(.v17),
    ],
    dependencies: [
        // 略
        ...
-       .package(path: "MyExecutable"), // ⭐️ 依存する実行ファイルは binaryTarget にあるので MyExecutable への依存はなくなる
    ],
    targets: [
        .target(
            name: "MyApp",
            plugins: [
                .plugin(name: "MyPlugin"),
            ]),
        .plugin(
            name: "MyPlugin",
            capability: .buildTool(),
            dependencies: [
                "MyExecutable",
            ]),
+       // ⭐️ artifact bundle を binaryTarget として参照する
+       .binaryTarget(
+           name: "MyExecutable",
+           path: "MyExecutable/MyExecutable.artifactbundle"),
    ]
)

ついでに、 MyExecutable/Package.swift 側も MyExecutable を product として公開する必要がなくなったので消しておきます。
MyExecutable/Package.swift

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyExecutable",
-   products: [
-       // ⭐️ artifact bundle にして外部から参照しなくなったので不要になった
-       .executable(
-           name: "MyExecutable",
-           targets: ["MyExecutable"]),
-   ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
        .package(url: "https://github.com/some/tools", from: "1.0.0"),
    ],
    targets: [
        .executableTarget(
            name: "MyExecutable",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "SomeTools", package: "tools"),
            ]),
    ]
)

完成 🎉

さて、これで MyApp をビルドしてみます。 MyExecutable はバイナリなので MyExecutable が依存するものがビルドされるということがなくなりました。

最後に

今回は artifact bundle を触ってみました。最初にプロポーザルを見た時はあまりピンと来ていなかったのですが、触ってみると思ったよりも難しい話ではなさそうでした。
とはいえ、私の解釈が間違っていることもあるかと思うので、何かありましたらご指摘いただけますと幸いです。

参照

*1:SE-0305 は、あくまで Binary Target の拡張という話なので正確にはプラグインに限った話ではなく、クロスプラットフォームへの柔軟性といった目的もあるようです。