デザインカンプの見出しにはカスタムフォントが使われており、ローカルでのデバッグも問題ないのに、自動ビルドしたAppをテスト端末へインストールすると、いつの間にかシステムフォントへ置き換わっていることがあります。この種の問題でコンパイルが失敗することは通常ありません。フォントがターゲットに含まれていない、Info.plist の相対パスが間違っている、またはコードでフォント内部のPostScript名ではなくファイル名を指定している、といった原因が考えられます。最も確実なのは、プロジェクトディレクトリを調べ続けるのではなく、クラウドMacでビルドした後の最終的なAppバンドルを直接監査することです。
フォント用CIゲートの検証項目を定義する
フォント用CIゲートでは、少なくとも4つの点を確認する必要があります。宣言されたファイルが存在するか、Core Textで解析できるか、内部名がコード側の規約と一致するか、複数のファイル間で名前が重複していないか、です。検査対象はソース内の Resources フォルダではなく、.app ディレクトリにします。ターゲットメンバーシップやCopy Bundle Resourcesフェーズによって、最終成果物の内容が変わる可能性があるためです。
| 検査項目 | 失敗が示すこと | 推奨対応 |
|---|---|---|
UIAppFonts エントリ |
ビルド構成でフォントが宣言されていない | ターゲットのInfo設定を修正する |
| Appバンドル内のファイル | フォントが成果物へコピーされていない | ターゲットメンバーシップとリソースフェーズを確認する |
| PostScript名 | コード内の指定名が誤っている可能性がある | フォント記述子から実際の名前を取得する |
| 名前の一意性 | フォントファミリーまたはバージョンが競合している | 重複ファイルを削除するか、置換関係を明示する |
画面上でフォントが「ほぼ同じに見える」ことを合格基準にしてはいけません。フォールバックが発生しても短い英語テキストでは気づきにくい一方、文字幅の変化は改行、ボタンサイズ、スクリーンショット比較に影響します。
リポジトリには ci/expected-fonts.txt を用意し、使用を許可するPostScript名を1行に1つずつ記録することを推奨します。これはコードからの参照とリソースファイルを結ぶ安定した契約であり、開発者のMacにインストールされている全フォントを記録するものではありません。
最終Appバンドルから事実を抽出する
まず既存のビルドフローでAppバンドルを生成し、そのパスを検査スクリプトへ渡します。DebugとReleaseではリソース構成が異なる場合があるため、リリースパイプラインでは実際に配布する構成を検査しなければなりません。また、検証前にスクリプトからシステムへフォントを登録してはいけません。同名のフォントがマシンにすでに存在すると、Appバンドル内の欠落を見逃す可能性があるためです。
次のSwiftスクリプトは、Info.plist の UIAppFonts を読み取り、各ファイルの存在を確認したうえで、Core Textから内部名を取得します。ci/check_fonts.swift として保存してください。
import Foundation
import CoreText
let arguments = CommandLine.arguments
guard arguments.count == 2 else {
FileHandle.standardError.write(Data("usage: check_fonts.swift /path/App.app
".utf8))
exit(64)
}
let appURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
let plistURL = appURL.appendingPathComponent("Info.plist")
guard
let data = try? Data(contentsOf: plistURL),
let plist = try? PropertyListSerialization.propertyList(from: data) as? [String: Any],
let declared = plist["UIAppFonts"] as? [String],
!declared.isEmpty
else {
FileHandle.standardError.write(Data("UIAppFonts is missing or empty
".utf8))
exit(1)
}
var failed = false
var seen = Set<String>()
for relativePath in declared {
let url = appURL.appendingPathComponent(relativePath)
guard FileManager.default.fileExists(atPath: url.path) else {
FileHandle.standardError.write(Data("missing: \(relativePath)
".utf8))
failed = true
continue
}
let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] ?? []
if descriptors.isEmpty {
FileHandle.standardError.write(Data("unreadable: \(relativePath)
".utf8))
failed = true
}
for descriptor in descriptors {
let value = CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute)
guard let name = value as? String, !name.isEmpty else {
FileHandle.standardError.write(Data("unnamed: \(relativePath)
".utf8))
failed = true
continue
}
if !seen.insert(name).inserted {
FileHandle.standardError.write(Data("duplicate: \(name)
".utf8))
failed = true
}
print("\(name) \(relativePath)")
}
}
exit(failed ? 1 : 0)
実行時には、ビルド成果物の実際のパスを使用します。
xcrun swift ci/check_fonts.swift "$APP_PATH" | sort > build/actual-fonts.txt
diff -u ci/expected-fonts.txt build/actual-fonts.txt
許可リストに名前だけを保存する場合は、出力後に cut -f1 を追加できます。重要なのは、差分があるときにゼロ以外の終了コードを返し、パイプラインを即座に停止させることです。
ファイル名ではなくPostScript名を固定する
Headline-Bold.otf はディスク上のファイル名にすぎず、フォント内部の名前は HeadlinePro-Bold かもしれません。SwiftUIの Font.custom、UIKitでのフォント初期化、テストコードでは、いずれも内部名を使用する必要があります。フォントファイルをリネームしても内部名は自動的には変わりませんが、提供元がフォントを更新すると内部名が変わる場合があります。
名前の変更を明示的なレビュー対象にする
actual-fonts.txt が許可リストと一致しない場合、パイプライン内で新しい値を自動承認してはいけません。まず、変更が計画されたアップグレードによるものか確認し、そのうえでプロジェクト内のフォント参照を検索します。フォント名は1つのソース定数ファイルに集約し、ビューコードやテストフィクスチャへ分散しないようにできます。
1つのフォントファイルに複数の記述子が含まれる場合もあります。スクリプトでは先頭の項目だけでなく、すべての名前を保持する必要があります。チームで可変フォントを実際に使用する場合は、よく使うウェイトについて画面レベルのスモークテストも追加し、軸の設定がデザイン基準と一致することを確認してください。
実行時検証とUI受け入れテストを追加する
静的なゲートによってリソース構造が正しいことは確認できますが、各画面で正しいウェイトが使われていることまでは証明できません。テストターゲット専用のフォント一覧画面を追加し、見出し、本文、数字、日本語の句読点、長い英単語を網羅したうえで、固定サイズのシミュレータによるスクリーンショット検証を行うことを推奨します。
実行時アサーションでは、point sizeだけを比較せず、フォントインスタンスの fontName を検査します。Dynamic Typeを使用する場合は、少なくとも2段階のコンテンツサイズカテゴリをテストし、拡大時に表示が切れないか確認する必要があります。フォントがない場合のフォールバックをUIで許容するなら、そのロジックをコード上に明示的に記述しなければなりません。一方、主要なブランドフォントが欠けた場合は、テスト環境で即座に失敗させるべきです。
並列テストでは、タスクごとに独立したビルドディレクトリと結果ディレクトリを使用し、あるブランチで生成した許可リストが別のブランチのものを上書きしないようにします。フォントファイルは入力リソースであり、ビルドスクリプトがその場で書き換えてはいけません。
検査を再現可能なパイプラインへ組み込む
全体の順序は、分離されたビルドディレクトリのクリーンアップ、対象構成でのビルド、唯一のAppバンドルの特定、フォント構造の検査、許可リストとの比較、最後にUIテストの実行とします。成果物の選択に曖昧な find | head -1 を使ってはいけません。同じディレクトリに複数のAppがあると、テストホストや古いビルドを検査してしまう可能性があります。
ゲートが失敗した場合は、actual-fonts.txt、Appバンドル内のフォントファイル一覧、ビルドログの関連部分を保存しますが、無関係な認証情報はアップロードしないでください。調査では、最初に欠落ファイル、次に UIAppFonts、最後に内部名を確認します。この順序なら、「パッケージに含まれていない」問題と「指定名が間違っている」問題をすばやく切り分けられます。
フォントのアップグレードが承認されたら、同じ変更内でフォントファイル、許可リスト、集約した名前定数、スクリーンショットのベースラインをコミットします。これにより、どのコミットをロールバックしても一貫した完全な状態へ戻せます。また、クラウドMacでのビルド結果が、特定の開発マシンに事前インストールされたフォントへ依存することもありません。
よくある質問
プロジェクト内のフォントファイル確認だけでは不十分ですか?
不十分です。Copy Bundle Resourcesやターゲット設定の影響で最終Appに入らない場合があるため、ビルド後のバンドルを検査します。
SwiftUIのcustomにはファイル名を指定しますか?
通常は内部のPostScript名を指定します。フォント記述子から実名を読み取り、期待値リストと照合する方法が安全です。
CIのどこでフォント検査を実行しますか?
インストール可能なAppバンドルを生成した直後、アップロードや配布処理へ進む前に実行します。
クラウドでMac miniワークフローを継続運用
RunAMac M4、レンタル期間、デプロイ先ノードを選び、開発、ビルド、実験のタスクを専有物理ノードで実行できます。