SwiftPM 6 は同時ビルド/テストを回避するために Package の .build ディレクトリをロックする

Swift version

swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx14.0

挙動について

SwiftPM 6 (release/6.0 以降)は、同時ビルド/テストを回避するために Package の .build ディレクトリをロックをするようです。残念ながら、このロックする挙動は一部のケースでプラグイン実行やテストの動作を止めてしまう可能性があります。 その影響を受けた場合以下のような warning が発生します。

Another instance of SwiftPM is already running using '/path/to/your/package-directory/.build', waiting until that process has finished execution...

プロセスが終了するまでロックされ続けるので、CIなどの実行で処理が完了せずにタイムアウトするようになってしまうなどの影響があります。

どのようなパッケージや実装が対象になるかというと、実行プロセスの中でさらに dump-packagebuild コマンドを実行しているケースです。厳密には try swiftCommandState.getActiveWorkspace() の箇所で起こりうるので、他のコマンドを同プロセスの中で実行していれば起きると思います。

Swift の VSCode Extension ではパッケージの依存解決をよしなにやってくれますが、従来の挙動だとユーザーがビルドしている最中にも依存解決が実行されたりしてリポジトリを破損されることが起きていたので同時にビルドが起きないようにこの対応が入ったようです。(という理解)

対応

escape-hatch として従来の挙動に戻せるコマンドオプション --ignore-lock が同時に追加されています。プロセスの中で実行している SPM コマンドに Swift 6 以上での実行かをチェックした上で --ignore-lock オプションを追加することで対処が可能です。

// swift --version などで Swift 6 以上での実行かを判断する(Regex)
if swift6Plus {
  let output = try sh("swift package dump-package --ignore-lock")
  ...
} else {
  ...
}

問題なくオプションを渡せていれば Another instance of SwiftPM is already running using '/path/to/your/package-directory/.build', but this will be ignored since --ignore-lock has been passed というwarningと共に処理がロックされずに後続に進むようになります。

参考

iOSDC Japan 2024 に行ってきた

8/22、23 に iOSDC Japan 2024 に参加してきました。24日のDay2は残念ながら都合が悪く参加できませんでしたが、実際に聞いたり&話したりできた範囲で感想を書きたいと思います。ちなみに iOSDC は初参加でした。


Inner Source Practice

開発を加速する共有Swift Package実践 by el_metal | トーク | iOSDC Japan 2024 #iosdc - fortee.jp を見て、お恥ずかしながら Inner Source Practice という言葉を知りました。

Inner Source Practice はOSS開発におけるベストプラクティスを活用して、非OSSな開発にもオープンソースの手法を取り入れ品質を高めようねという理解をしました。サイボウズは異なる時期に開発されたアプリ(kintone、サイボウズ Office、Garoon?)で、認証機能などの共通部分がそれぞれ再実装されていたことが問題としてあったそう。プロジェクトごとに実装が分かれているため新しいメンバーへのオンボーディングにコストがかかってしまう課題もあると言っていて、これは複数プロダクトを抱えるとどうしてもある話だなと思いました。これらの課題を共通の Swift Pacakage を実装することで解決していったとのことでした。利用側のアプリ分バグもスケールしてしまうけど、SSoT になったおかげでバグ修正のスピードも早まったとおっしゃっていていい話でした。開発体制は OSS のように Owner 、Contributer などを定めて進めていて、ちゃんとワークさせているのがすごいなと。これはチーム内の理解もかなり必要そう。利用側のパッケージ更新はひとまず main 指定でやっていると言っていて、この辺りは今後の課題なのかな〜?という感想です。自分が所属するチームでも共通 Package を取り組む将来はあり得なくはないので、興味深く聞いていました。

Swift Concurrency

みんな大好き(?) Swift Concurrency。 Swift 6 もきて盛り上がっていますが、まだまだ理解が十分でない部分が多いので、それらを補完するようにトークを聞いてきました。

まず、大前提として Concurrency が防いでくれるのは Data race(競合)で、Race condition(競合状態)じゃないよというのは確かにでごっちゃになるので要注意ですね。個人的に印象的だったのは隔離境界(Isolation Boundary)を越えなければ難しくないという部分でした。所属チームのプロジェクト でも ViewModel は MainActor にしていこうねというところで、MainActor でいいところは怖がらずに MainActor にしていけばいいという話を聞けたのは良かったです。 境界を越えようとすると途端に難しくなるので、そういうシチュエーションになったらこの時のトークを振り返ったりしたいと思います。

Mergeable Library

WWDC23 で発表された Static/Dynamic に続く新たなライブラリの形式「Mergeable Library」のお話。

開発時に嬉しい Dynamic Framework と、 リリース時に嬉しい Static Framework のいいとこ取りしてくれるので最高ではという印象でしたが、ベンチマークはあんまりいい結果になっていない?ようでした(色々考察がある)。リソースバンドルもいい感じに扱ってくれるようなのでうまくいけば絶対嬉しいはずです。ワードは知っていたけど深く終えていなかった部分なので、とても面白かったです。 活用事例は大募集中のようなので、実際のプロダクトでやってみないとわからない部分も多いだろうし機会を作って試してみたいですね。

参加してみて

自分は残念ながらプロポーザルが採択されなかったので登壇することはできませんでしたが、プロポーザルに出した内容は、アフターイベントである iOSDC 2024 後夜祭 - connpass でお話しできる機会をいただいたのでLTで発表してきました。

speakerdeck.com

来年こそはスピーカーとして参加したいですね。いいネタができるようまた一年頑張っていきたいと思います。

FCM Batch send API の v1 API への移行方法

以前書いたブログで、バッチ送信と呼ばれるFCMへの単一のHTTPリクエストに複数の送信リクエストを含めることができるAPIが廃止される件について書きました。 fxwx23.hatenablog.com

この件がなんとか決着をつけられそうなので、どう対応していくかについてまとめます。

Legacy API を引き続き使えるように要請する(Optional)

前提として該当の API は6月20日以降に動作しなくなると言われていました。終了日2日前に "[Reminder] Update your apps to the latest Firebase Cloud Messaging APIs and SDKs" というメールが届いていて、そこにはサービスに影響させたくない場合は延長リクエストを送る事ができますよと書いてあったので、さっそく Firebase Support に連絡します。

サポートの返事には、沢山リクエストがあったのでひとまず7月20日まで伸ばしましたという報告と、さらに延長したい場合はGoogleフォームに必要項目を記載して提出してねと言われました。フォームには最長9月末まで延長できそうな感じだったので9月末を選択、延長理由は公式SDKがHTTP2にまだ対応してないからと記載しました。提出から数日後に延長許可のメールが届いたので無事に受理されたようでした。

ひとまず引き伸ばしたいという方はまず Firebase の Support に連絡してみると良さそうです。

公式SDKを HTTP/2 対応ver に更新する

前回のブログでも対応方針として HTTP/2 対応するしかないと紹介しましたが、公式の Firebase Admin SDK 側でも対応が進みつつあります。 7月30日時点ではまだ NodeJS SDK しか対応されていません。 Go や Java のほうでは issue はありますが、PR などはまだ出てきていない状況です。

実際に対応された NodeJS SDKv12.3.0 に移行してみます。

- "firebase-admin": "12.2.0"
+ "firebase-admin": "12.3.0" 

やることとしては、 sendAllsendMulticastsendEachsendEachForMulticast に入れ替えるだけです。 12.3.0 では基本的に HTTP/2 のクライアントを利用してくれるようなので特別な設定は不要のようです。

実装は feat(fcm): Add HTTP2 support for `sendEach()` and `sendEachForMulticast()` by jonathanedey · Pull Request #2550 · firebase/firebase-admin-node · GitHub で確認することができます。いい感じにリトライ処理もしてくれるし、Token の最大数も 500 のままで大丈夫そうなので安心です。

ただ基本的にすべての送信で HTTP/2 が採用されてしまうことには注意が必要です。12.3.0 に更新して一部条件では延長期限まで sendMulticast を使い、その他では sendEachForMulticast に移行するというケースを例にします。

const messaging = getMessaging(app);

let response: BatchResponse;
const useHttpV1API = true; // 任意の条件
if (useHttpV1API) {
  response = await messaging.sendEachForMulticast(message);
} else {
  response = await messaging.sendMulticast(message);
}

Node.js の Admin SDKsendEachsendEachForMulticast では基本的に HTTP/2 のクライアントが利用されます。場合によって引き続き HTTP/1.1 のクライアントを使いたいケースがあるかもしれません。 その場合は送信処理を実行する前に enableLegacyTransport() メソッドで切り替えることができます。

気になるのはパフォーマンスですが、Legacy API と比較する必要があるのでそれはまた別途やってみて共有できそうならまたブログを書きたいと思います。

Plugin から別ディレクトリにあるネイティブコードを参照する時は Symbolic Link を使う

Swift Package Manager の Plugin から Sources や 親階層の別ディレクトリにあるネイティブコードを参照したい時は、シンボリックリンクを使えばいいようです。 swift-docc-plugin が参考になりました。

github.com

この方法は Plugin 間でコードの共有ができるまでの間の一時的なワークアラウンドとされています。

This is a workaround until SwiftPM has native support for sharing code between plugins.

Sources/YourTool があったとして、 Plugins/YourTool から参照としたいとします。 この時 Plugins/YourTool/Symbolic-Links を作ってそこにシンボリックリンクを作成してあげるだけです。

$ cd Plugins/YourTool/Symbolic-Links
$ ln -s ../../../Sources/YourTool YourTool

とても単純な話ですが、 Command plugin を自作する際にインプットした情報の備忘録として残しておこうと思います。

Privacy report を生成できる Command Plugin を作った

その名の通り。All Swift, no dependencies.

github.com

Privacy report とは、アプリと利用するパッケージが収集しているプライバシー情報を要約した PDF ファイルです。 App Store の Privacy Nutrition Labels に対応しており、 このレポートを見ながらストア上の情報を更新することができます。

Privacy report - Get started with privacy manifests より

レポートは Xcode の Organizer からしか生成することができず、出力も PDF なのでパッケージ更新時の差分比較がしにくいな〜と感じていました。理想はストア上のプライバシー情報が自動で反映されてくれることなのですが、いまのところそうなる気配もなく、ドキュメントにもレポートを参照して設定するといいよというニュアンスなので、

Refer to this report when you provide your app’s privacy details in App Store Connect.

比較しやすいフォーマットのファイルで出力できたらいいなということで作ったのがこのツールです。Xcode で生成するレポートと同じ内容で出力されるはずです(違ったら教えてほしい)。Plist と JSON にひとまず対応しています。SPM の Command plugin 経由で実行できるようにしてあります。

$ swift package plugin --allow-writing-to-package-directory generate-privacy-report --xcarchive-path '/path/to/your/App.xcarchive' --json

JSON だとこんな↓感じで出力してくれます。各プライバシーデータの項目に対して、どの経路で利用されているのかわかるようになっています。

{
  "NSPrivacyCollectedDataTypeAdvertisingData" : {
    "SampleAd_resources.bundle" : {
      "bundlePath" : "SampleApp.app/SampleAd_resources.bundle/PrivacyInfo.xcprivacy",
      "collectionPurposes" : [
        "NSPrivacyCollectedDataTypePurposeAnalytics",
        "NSPrivacyCollectedDataTypePurposeDeveloperAdvertising",
        "NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising"
      ],
      "isLinkedToUser" : true,
      "isUsedForTracking" : true
    },
    "SampleApp.app" : {
      "bundlePath" : "SampleApp.app/PrivacyInfo.xcprivacy",
      "collectionPurposes" : [
        "NSPrivacyCollectedDataTypePurposeAnalytics",
        "NSPrivacyCollectedDataTypePurposeAppFunctionality"
      ],
      "isLinkedToUser" : true,
      "isUsedForTracking" : false
    }
  }
}

その他オプションなどは README に書いてあります。実用的はまだわからないけど、定期配布の時にレポートに差分がでたらPRを作ってくれるとかやれると良さそうとか妄想中です。

ひとまず作ったよ報告でした👋

【iOS】XcodeSelectiveTesting を使う際の Tips

XcodeSelectiveTesting という OSS のツールを活用して、変更箇所に依存するテストターゲットのみを実行することができます。

github.com

このツールは Git の変更履歴から差分を分析してどのモジュールに対して変更があったのかを検知し、モジュール間の依存関係をもとにして、あるモジュールの変更が他のどのモジュールに影響を与え得るかを検出してくれます。パッケージ間の依存関係は swift package dump-package コマンドを使い、 Xcodeプロジェクトとターゲット間の依存関係は tuist/XcodeProj を使って解析しています。

マルチターゲット、マルチモジュール構成のプロジェクトであることが前提で、利用方法は README に書いてある通りなんですが、実際にプロジェクトに導入を進める際にいくつかハマりポイントがあったので掻い摘んで紹介します。

XcodeGen との併用

導入しようとするプロジェクトが XcogeGen を利用している場合、上述した tuist/XcodeProj を両方のツールが利用しておりバージョン解決ができません。Package.swift を分けるか、 artifactbundle をサポート済みなのでそちらを使うことをお勧めします。

ただ、本家のほうはpre-built なバイナリを使った Command Plugin をまだ用意してくれていないので、自前で用意したものを使っています。

github.com

上記を使うことで XcodeGen と同じ Package.swift で管理しつつ、ビルド時間の削減も実現できるので一石二鳥というわけです。

fastlane との連携

テストの実行で fastlane を使っている場合は一手間が必要です。 TestPlan を利用せずに fastlane の run_tests で意図したターゲットのみを実行するには only_testing のパラメーターにターゲットの情報を渡す必要があります。only_testing には Test Bundle/Test Suite/Test Cases の文字列の配列を渡すことができるので、その情報を渡せるよう lane を調整する必要があります。

lane :selective_test do |options|
  if options[:targets].nil? then
    UI.user_error!("targets parameter is invalid or missing.")
  end
  
  if options[:targets].empty? then
    UI.user_error!("targets parameter is empty")
  end

  run_tests(
    ...
    only_testing: options[:targets],
  )
end

fastlane ではパラメーターを渡すことができ、配列データを渡す場合はカンマ区切りの文字列として渡すことで実現できます。

$ bundle exec fastlane selective_test targets:"ATests,BTests"

最終的にはここに XcodeSelectiveTesting の実行で得たターゲット情報を渡すことで意図したテストのみを実行することが fastlane 経由でも可能になります。

TestPlan で管理していると、 XcodeSelectiveTesting がスキップするべきテストを認識し xctestplan ファイルを更新してくれます。TestPlan を使っていない場合は、そちらに移行することをまず検討してみてもいいかもしれません。

JSON 出力の取り扱い

XcodeSelectiveTesting は JSON 形式の出力をサポートしています。 fastlane や xcodebuild コマンドに情報を与えていく場合は jq を使って JSON から必要な情報を抽出&整形して活用する必要があります。

swift run xcode-selective-test --json で実行したとして、出力されるJSONは変更箇所に依存するターゲットがすべて含まれるため、以下のようになっています。

[
  {
    "name" : "YourApp",
    "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
    "type" : "target",
    "testTarget" : false
  },
  {
    "name" : "YourUITests",
    "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
    "type" : "target",
    "testTarget" : true
  },
  {
    "name" : "YourTests",
    "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
    "type" : "target",
    "testTarget" : true
  }
]

ここからテストターゲットのみを抽出したいので、 jq の select を使います。最終的には YourUITests,YourTests というカンマ区切りの文字列が欲しいので namemap した上で join するといい感じに欲しい形にできました。

jq -r "[.[] | select(.testTarget == true)] | map(.name) | join(\",\")"

-r オプションでRAW出力にしておいてから、"" で囲むとうまく配列のデータとしてfastlaneに渡すことができたので、最終系はこのような感じになりました。

bundle exec fastlane selective_test targets:"$(swift run xcode-selective-test --json | jq -r "[.[] | select(.testTarget == true)] | map(.name) | join(\",\")")"

Command Plugin での実行

XcodeGen との併用のところで自前で用意した Command Plugin を利用していると書きましたが、Command Plugin 経由で実行する場合は少し込み入ったことをするはめになったのでそれを最後に紹介します。

SPM の問題*1なのか詳細はわかっていないのですが、JSON 出力以外の実行中に発生したログもすべて stdout として認識されており jq のパースで失敗してしまいます。JSON 箇所だけが欲しいので、その部分だけを抽出する一手間が必要になりました。JSON の出力は必ず [{ ... }] なのでそこだけ抜き出すようにします。

# OUTPUTS は xcode-selective-test の実行結果
# 
# -- OUTPUTS はこのような感じです --
# Finding changeset for repository at YourApps.xcworkspace
# [WARN]: Path xxx is mentioned from package at xxx but does not exist
# [WARN]: File without path: self=XcodeProj.PBXBuildFile, 
#  self.file=Optional(XcodeProj.PBXVariantGroup), 
#  self.product=nil
# [WARN]: Path xxx is mentioned from package at xxx but does not exist
# [ <-- ここから
#   {
#      "name" : "YourTests",
#      "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
#      "type" : "target",
#      "testTarget" : true
#   }
# ] <-- ここまでが欲しい
# -----------
#
# ref: https://github.com/mikeger/XcodeSelectiveTesting/blob/0.9.4/Sources/SelectiveTestLogger/Logger.swift
#
OUTPUT_JSON=$(echo "$OUTPUTS" | grep -v '^\[WARN' | grep -v '^\[ERROR' | sed -n '/\[/,/\]/p')

出力されたログには [WARN][ERROR] が混ざっている場合がありそれらも対象になってしまうので事前に grep しておくと最後のJSONの配列部分だけを抜き出すことができます。

ここまでするなら swift run でもいい気もしますが、どうしても Command Plugin でという方には参考になるかもしれません。

最後に

XcodeSelectiveTesting の導入を実際のプロダクトに導入する際の Tips を紹介しました。コードベースが大きくテストターゲットがたくさんあるプロジェクトでは少なからず効果がありそうな印象です。都度すべてのテストが実行されてしまうのは時間やCIリソースの面で改善余地があるので、そういった面でもどれほどの効果があるかはしばらく運用してみたで紹介しようと思います!

*1:この辺が関係しているかも? https://github.com/apple/swift-package-manager/issues/7589

workflow_dispatch トリガーの検証方法

とある OSSworkflow_dispatch トリガーを持つ GitHub Actions の実装をした Pull Request を出すことがあった。 workflow_dispatch はワークフローを手動でトリガーできるようにするとっても便利なやつ。

docs.github.com

自分は workflow_dispatch はデフォルトブランチで反映できていないと実行できないと認識していて、公式ドキュメントにもそう書いてある。

Note: This event will only trigger a workflow run if the workflow file is on the default branch.

Pull Request で新しく workflow_dispatch のアクションを追加定義した場合、当然デフォルトブランチにマージする前に動作を確認したい。Push 度に実行されても問題ないジョブならそれでいいのだけど、ファイルをアップロードしたり作成したりする場合はそうもいかないので、任意のタイミングで実行したい。

困ったな〜と思って色々調べてみるとどうもデフォルトブランチ以外でも実行できるらしい。とても単純な話で GitHub がそのワークフローを認識できていれば GitHub API (CLI) から実行可能だった。

やりかた

まず、 GitHub がワークフローを認識できるようにするために一度 on: push にした状態で Push する。*1

name: workflow dispatch test

on:
  workflow_dispatch:
  push:
    branches:
      - your-current-feature-branch-name

そうすると当然 GitHub Actions が動き出す。動き出すことが重要で、一度ワークフローが動くことによってワークフローが認識される。(つまり ID が付与された状態になる。)

認識されているかどうかは、GitHub CLI を使って確認することができる。YAMLname に定義したワークフローが表示されていればOK。

$ gh run list --workflow=your-workflow.yaml

あとはお好きなタイミングで実行ができる。ドキュメントにも ref が Body Parameters として定義されていて、ブランチ名やタグが指定できる様子。

$ gh workflow run "workflow dispatch test" --ref your-current-feature-branch-name

*1:一度認識できればon: pushな状態は不要なのですぐ消してしまおう