project.pbxprojのコンフリクトをTuist導入で構造的になくす
私の所属している株式会社DIGITALIOでは、ポイントメディアサイトECナビを運営しており、 ECナビアンケートアプリ というモバイルアプリも展開しています。
本記事では、iOSアプリの並行開発で問題になっていた project.pbxproj のコンフリクトを、Tuist導入によって構造的になくした話をお伝えします。
tuist/tuist
TL;DR
想定読者: iOSアプリの開発で project.pbxproj をGit管理している方
Tuist: Swiftで書いた定義ファイルからXcodeプロジェクトを生成するCLIツール
定義ファイルから project.pbxproj を毎回生成できるので、生成物をGit管理から除外できた
project.pbxprojをGit管理することの難しさ
本アプリは、複数人で並行開発を行っています。Xcodeプロジェクトの実体は ECNaviEnqueteApp.xcodeproj/project.pbxproj という単一のテキストファイルです。ファイルの追加や設定変更のたびに、Xcode経由でこれを書き換える必要がありました。
私たちのチームは project.pbxproj をGit管理していたため、複数のブランチで同時にファイル追加や設定変更が発生すると、このファイルはコンフリクトしました。特に並行開発が増えた時期には、複数の行にまたがるコンフリクトの解消に手間がかかっていました。
PR1「project.pbxprojにファイルを追加してmasterにマージした!」
PR2「project.pbxprojにファイルを追加してmasterにマージするぞ!」
│
▼
_人人人人人人人人人_
> コンフリクト!! <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^ ̄
<<<<<<< HEAD
(PR1がpbxprojに追加した内容)
=======
(PR2がpbxprojに追加した内容)
>>>>>>> PR2
(;´Д`) 手作業でdiffを解読するしかない…
また、実装ファイルだけをXcodeプロジェクトに追加し、テストファイルの追加を忘れることもありました。ビルドターゲットに含まれないテストは実行されず、CIも通るため、マージ後に追加漏れが発覚します。
これらの問題に加えて、Xcode上のGUI操作でプロジェクトを管理する方式には、次の2つの課題もありました。
設定変更がXcode上のGUI操作に依存するため、手順が煩雑になる
project.pbxproj は人間にもAIエージェントにも読み解きにくく、変更の意図を追いにくい
これらの課題を根本からなくすため、定義ファイルから project.pbxproj をCLIで自動生成する運用への切り替えを検討しました。生成物をGit管理から除外できるため、 project.pbxproj に起因するPR間のコンフリクトをなくせます。
XcodeGen・Tuist・Bazelの比較
プロジェクト生成ツールとして候補に挙がったのはXcodeGen・Tuist・Bazelの3つです。選ぶ条件は次の3つでした。
定義ファイルの誤りを、型とコンパイラで防げる
既存のCocoaPods運用を維持したまま導入できる
既存のビルドシステムを置き換えない
この条件で各ツールを比較しました。
観点
XcodeGen
Tuist
Bazel
定義形式
YAML
Swift DSL
Starlark
定義ファイルの型検査1
なし
あり
なし
CocoaPods互換性2
○
○
△
ビルドシステムの置き換え
なし
なし
あり
結果、Tuistを導入することにしました。TuistはSwiftで記述できるため、引数の型や構文の誤りを定義ファイルのコンパイル時に検出できます。
XcodeGenは、YAMLゆえの型安全性の低さが気になりました。Bazelはリモートキャッシュや再現可能なビルドの面で強力な一方、Xcodeのビルドシステムごと置き換える規模感が今回の目的には釣り合わないと判断しました。
!
選定後に知ったことですが、XcodeGenとTuistはどちらも tuist/XcodeProj を利用しています。Xcodeプロジェクトの読み書きを担う層は共通のため、この層に限れば、どちらを選んでも継続性の懸念は小さいと考えています。
Tuist導入の進め方
導入は影響範囲を絞るため3段階のPRに分割しました。
miseによるツールのバージョン固定
miseは、Node.jsのnvmやasdfのように、ツールのバージョンをプロジェクト単位で固定できるツールです。
mise.jdx.dev
Tuistはインストール方法にmiseを推奨している3ため、本アプリでもバージョン固定にmiseを採用しました。
まずは .mise.toml を導入し、開発者間でTuistのバージョン差異が生じないようにしました。CIでも jdx/mise-action を使い、ローカルと同じバージョンのTuistを利用しています。
[tools]
tuist = "4.195.9"
Project.swiftの定義とMakefile整備
次に、定義ファイルの追加と便利コマンドの整備を行い、自動生成の運用フローを整えました。
Project.swift
既存のビルドターゲット・スキーム・ビルドスクリプトを Project.swift で再現し、従来と同等のビルド動作を維持しました。
import ProjectDescription
private let appDependencies: [TargetDependency] = [
.sdk(name: "AdSupport", type: .framework, status: .optional),
]
private let appTarget = Target.target(
name: "ECNaviEnqueteApp",
destinations: .iOS,
product: .app,
deploymentTargets: .iOS("15.0"),
sources: [
.glob(
"ECNaviEnqueteApp/", // ...
),
],
resources: [
"PrivacyInfo.xcprivacy",
.glob(pattern: "ECNaviEnqueteApp/", excluding: commonResourceExclusions),
],
dependencies: appDependencies,
// ...
)
let project = Project(
name: "ECNaviEnqueteApp",
targets: [appTarget, appDevTarget, testsTarget, previewTarget],
schemes: [appScheme, appDevScheme, previewScheme, testsScheme],
// ...
)
ビルドターゲットのファイルはglobで指定するため、ファイルを追加しても定義ファイルの編集は不要です。
CocoaPodsの統合方法
本アプリは当時CocoaPodsで依存関係を管理しており、 pod install が生成済みのXcodeプロジェクトへビルドフェーズやxcconfig参照を書き込む構成でした。そのため、 tuist generate から pod install までを一括で実行する make sync-develop 等を Makefile に用意しました。ここで地味にハマったのが、 pod install の挙動です。
sync-develop:
環境ごとに差し替えるファイルを事前にコピー
cp ECNaviEnqueteApp/Resource/GoogleService-Info-Develop.plist ECNaviEnqueteApp/Resource/GoogleService-Info.plist
Xcodeプロジェクト生成と依存関係の解決
tuist generate --no-open
bundle exec pod install
CocoaPodsには、依存関係に変更がなければ、変更のないPodのターゲットの再生成とプロジェクトへの統合を省略する 「インクリメンタルインストール」 (Incremental Pod Installation)があります。この仕組みは、 Pods/.project_cache/ に保存した前回のキャッシュキーと、今回の依存関係やビルド設定から作ったキャッシュキーを比べて変更の有無を判断します。このときアプリ側のXcodeプロジェクトの状態は見ません4。本アプリの Podfile では、当初 :incremental_installation => true を指定して有効にしていました。
install! 'cocoapods',
:generate_multiple_pod_projects => true,
:incremental_installation => true
...
一方、Tuistは tuist generate のたびにXcodeプロジェクトを完全に再生成するため、直前にCocoaPodsが追加したビルドフェーズやxcconfig参照は消えます。 tuist generate 直後の pod install では、 Pods/ 側のキャッシュが残っているため変更なしと判断され、ビルドフェーズやxcconfig参照が書き込まれませんでした5。原因はインクリメンタルインストールの差分判定にあったため、 :incremental_installation => false で無効化したところ、毎回書き込まれるようになりました。
!
何らかの理由でインクリメンタルインストールを true のまま維持する場合は、 bundle exec pod install --clean-install で同様に統合できます。
project.pbxprojなどをGit管理から除外
第2段階でプロジェクト生成が安定して行えることを確認できたため、最後に ECNaviEnqueteApp.xcodeproj/ と ECNaviEnqueteApp.xcworkspace/ を git rm --cached でGit管理から除外しました。
Tuist
Derived/
ECNaviEnqueteApp.xcodeproj/
ECNaviEnqueteApp.xcworkspace/
これにより project.pbxproj に起因するコンフリクトは発生しなくなりました。 Project.swift の同時編集によるコンフリクトは残りますが、読みやすいSwiftファイルの差分になります。生成物が手元に存在しない状態になるため、チームへの周知とドキュメント整備も合わせて行いました。
環境構築手順をまとめた docs/setup.md に、プロジェクト生成と依存関係のインストールの手順を追加した
AIエージェント向けの CLAUDE.md に、 make sync-develop が必要になるタイミングとコマンドを記載した
ドキュメントの記載例## プロジェクト生成と依存関係のインストール
ECNaviEnqueteApp.xcodeproj/ と ECNaviEnqueteApp.xcworkspace/ はTuistによって自動生成されるためGit管理から除外されており、以下のコマンドによって管理している。
| コマンド | 用途 |
| --- | --- |
| make sync-develop | 開発用の GoogleService-Info.plist を配置し、Tuistでプロジェクトを生成、CocoaPodsを統合する |
| make sync-release | 本番用の GoogleService-Info.plist を配置し、Tuistでプロジェクトを生成、CocoaPodsを統合する |
以下のケースで make sync-develop の実行が必要:
- git clone の直後
- git pull やブランチ切り替え( Project.swift やファイル構成に差分がある場合)
- 依存関係の更新
- ファイルの追加・削除
make init は内部で sync-develop を呼び出すため、初回セットアップ時は make init を実行するだけでよい。
Xcodeを開いた状態で sync-* を実行した場合
Xcodeでプロジェクトを開いたまま make sync-* を実行すると、Xcodeプロジェクトが外部から書き換えられたことをXcodeが検知し、ダイアログが表示される。
「Use Version on Disk」を選択する。 make sync-* によって生成された最新のファイルがXcodeに反映される。
導入後の変化
記事の前半で挙げた4つの課題は、次のように変わりました。
課題
導入後
PR間のコンフリクト
project.pbxproj 起因はなくなった
ファイルの追加漏れ
sources のglobに一任
GUI操作の煩雑さ
Project.swift の編集と make sync-develop で完結
解読の容易性
定義ファイルの差分として追えるようになった
一方で、トレードオフもあります。 git clone 直後のほか、ブランチの切り替え、ファイルの追加・削除、依存関係の更新のたびに make sync-develop の実行が必要になりました。
また、実行のたびにXcodeプロジェクトを再生成する関係上、次の2点にも注意が必要になりました。
一時的に有効にしたい設定はXcode上のGUI操作で変更しますが、プロジェクトの再生成時に失われます。例えば、デバッグ用に追加した FIRDebugEnabled などの起動引数です。
Xcodeを開いたまま実行すると「The file "..." has been modified by another application」というダイアログが出ます。そのため、その都度Xcodeを再読み込みし、ディスク上の最新状態を反映する運用にしました。
コンフリクトの解消は内容を読み解く判断が必要でしたが、CLIで再生成する場合の結果は定義ファイルだけで決まり、失敗しても再実行で同じ状態に戻せます。この性質の違いから許容できると判断し、コマンドを Makefile にまとめ、手順をドキュメントに残しました。
まとめ
project.pbxproj に起因するコンフリクトは、Tuist導入によって構造的になくせました。第1段階〜第3段階という小さなステップに分けたことで、各PRのレビューと動作確認の負荷を抑えられたのも良かった点です。
同様の課題を抱えている場合は、まず1つのビルドターゲットを Project.swift で再現し、生成物が既存の project.pbxproj と同等になることを確認してからGit管理から除外するのがおすすめです。除外した後は自動生成が通常の運用フローになるため、周知も忘れずに行いましょう。
脚注
ここでの型検査は、定義ファイルの型の誤りをコンパイル時に検出できることを指す。XcodeGenのYAMLとBazelのStarlarkは静的型を持たず、誤りは生成や評価の実行時に分かる。 ↩︎
XcodeGenとTuistはCocoaPodsを管理せず、プロジェクト生成後に pod install を実行して併用。Bazelには公式のCocoaPods統合がなく、 rules_ios やサードパーティ製のツールを使うか、Podごとにビルド設定やリソースの扱いを調整する必要がある。 ↩︎
Install Tuist · Guides · Tuist を参照。 ↩︎
判定ロジックは project_cache_analyzer.rb と target_cache_key.rb にある。設計の背景は Phase 1 、 Phase 2 を参照。 ↩︎
Pods/.project_cache を退避すると統合が復活し、 Manifest.lock や Podfile.lock を退避しても復活しないことをCocoaPods 1.16.2で確認している。 ↩︎
It May Be a Good Idea to Verify CLI Binaries Used in GitHub Actions with Both SHA256 Checksums and Attestations
---
title: "It May Be a Good Idea to Verify CLI Binaries Used in GitHub Actions with Both SHA256 Checksums and Attestations"
published: true
description: "Why I verify CLI binaries downloaded from GitHub Releases with both a SHA256 checksum and gh attestation verify, and how gh release verify-asset can help for immutable releases."
tags: ["github", "githubactions", "security"]
cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com
canonical_url: masutaka.net
---
In masutaka/actions, which I introduced in [2026-02-23-1], CLI binaries such as jactionlint are downloaded from each tool's GitHub Releases.
The downloaded binaries are verified with both a SHA256 checksum and gh attestation verify. In this post, I'll explain how and why.
Verifying with SHA256 Checksums and Attestations
Here's how masutaka/actions verifies jactionlint:
:link: .github/workflows/gh_action_lint.yml
- name: Install jactionlint
env:
GH_TOKEN: ${{ github.token }}
REPO: jdx/jactionlint
ASSET: "*_linux_amd64.tar.gz"
TAG: v2.0.2
ASSET_SHA256: 026bce6d0ef196f5b22bf43acb1348d47c5d0a5ae61d6ec3bdba1229a823161b
SIGNER_WORKFLOW: jdx/jactionlint/.github/workflows/release.yaml
EXECUTABLE: jactionlint
run: |
gh release download "$TAG" \
--repo "$REPO" \
--pattern "$ASSET" \
--output asset.tar.gz
# 正規の release workflow がそのタグからビルドした成果物であることを検証する
gh attestation verify asset.tar.gz \
--repo "$REPO" \
--signer-workflow "$SIGNER_WORKFLOW" \
--source-ref "refs/tags/$TAG"
# ピンした時点から中身が変わっていないことを検証する
echo "${ASSET_SHA256} asset.tar.gz" | sha256sum -c -
tar -xzf asset.tar.gz "$EXECUTABLE"
It downloads the asset from GitHub Releases and uses it only if it passes both gh attestation verify and sha256sum -c.
--signer-workflow only checks the workflow path; it doesn't pin the ref or commit. So I use --source-ref to restrict it to artifacts built from the recorded tag.
The attestations checked by gh attestation verify have to be generated by the publisher in their release workflow. For the publisher's side of the story, Songmu's post goes into detail:
:link: GitHub Actions上で証明書を付与するサプライチェーン構築 | おそらくはそれさえも平凡な日々
After reading it, I also migrated my own github-nippou to actions/attest.
By the way, --signer-workflow can point to a different repository than --repo. For example, ghalint is signed by suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml, a reusable workflow called from its release.yaml.
Why Verify with Both?
Because SHA256 checksums and gh attestation verify verify different things.
- SHA256 checksum
- Verifies that "the content hasn't changed since the checksum was recorded"
- Even a binary built locally without going through CI will match if you record its value
- gh attestation verify
- Verifies that "the artifact was built from that tag by the legitimate release workflow"
- However, unless it's an immutable release, tags can be moved. If an attacker gains write access, moves the tag to a tampered commit, and rebuilds, it still passes
- It also passes if the artifact is rebuilt under the same tag and the asset is overwritten. For example, when a build-time dependency gets compromised later on
I couldn't find any official recommendation to use both, but I did find some supporting information.
The GitHub Docs page on Artifact attestations says that "artifact attestations are not a guarantee that an artifact is secure."
There's also SLSA (Supply-chain Levels for Software Artifacts), an OpenSSF framework that defines levels of software supply chain security. The build provenance checked by gh attestation verify is also in the SLSA format.
SLSA's Threats & mitigations states that the threat of compromised build-time dependencies is out of SLSA's scope. The mitigation it suggests is pinning dependencies by digest:
> You may be able to mitigate this threat by pinning your build dependencies, preferably by digest rather than version number.
This is aimed at publishers, but I think users pinning by checksum follows the same idea.
That said, checksums only protect against replacement after you've recorded them. If a dependency was already compromised at the time of the first release, both the checksum and the attestation will pass.
Personally, I'd be happy if gh attestation verify could also check that the artifact matches a recorded checksum, but for now I'm verifying with two separate methods.
If There's No Build Provenance, Release Attestations May Help
What follows is something I learned while writing this post. I've only tried it locally and don't use it in masutaka/actions.
gh attestation verify verifies build provenance, so it can't be used unless the publisher generates it.
For example, tfupdate, a tool that bulk-updates version constraints for Terraform core and providers, doesn't generate build provenance, as you can see from the repository's Attestations page.
So gh attestation verify fails with a 404:
$ gh attestation verify asset.tar.gz --repo minamijoyo/tfupdate
Error: HTTP 404: Not Found (https://api.github.com/repos/minamijoyo/tfupdate/attestations/sha256:559bc7c6c15496682a3207cbdc4e6327de252f5f1a4348f5098027d6bcc4ebe2?per_page=30&predicate_type=https%3A%2F%2Fslsa.dev%2Fprovenance%2Fv1)
Even so, if the repository has immutable releases enabled, you can use release attestations. GitHub generates them automatically for releases published after the setting is turned on.
A release attestation lets you verify that "this asset was included in the release for this tag." It doesn't show up on the Attestations page, so check the release page to see whether it's an immutable release. tfupdate v0.10.2 is an immutable release, so it can be verified with gh release verify-asset:
$ gh release verify-asset v0.10.2 asset.tar.gz --repo minamijoyo/tfupdate
Calculated digest for asset.tar.gz: sha256:559bc7c6c15496682a3207cbdc4e6327de252f5f1a4348f5098027d6bcc4ebe2
Resolved tag v0.10.2 to sha1:9d61d78d73e1325629e46c9e92d713ab258b50cd
Loaded attestation from GitHub API
✓ Verification succeeded! asset.tar.gz is present in release v0.10.2
This method is described in the GitHub Docs page Verifying the integrity of a release.
A release attestation doesn't tell you whether the artifact was built by the legitimate workflow. Still, it does confirm that the downloaded file is the same as the asset in that release.
With an immutable release, tags can't be moved and assets can't be overwritten, so it also closes the tag-moving loophole I mentioned in "Why Verify with Both?"
Conclusion
It seems like a good idea to verify CLI binaries used in GitHub Actions and elsewhere with both SHA256 checksums and gh attestation verify. The checksum confirms the content hasn't changed, and the attestation confirms it was built by the legitimate release workflow.
Even if the publisher doesn't generate build provenance, gh release verify-asset looks usable for immutable releases. I've only tried this one locally.
Here's a comparison of the three methods:
| Verification | What it confirms | What the publisher needs |
|---|---|---|
| SHA256 checksum | The content hasn't changed since the checksum was recorded | Nothing (a checksums.txt lets you cross-check the value you record) |
| gh attestation verify | It was built from that tag by the legitimate release workflow | Generating build provenance |
| gh release verify-asset | It was included in the release for that tag | Enabling immutable releases |
Another option is to install via mise-action. For tools using the aqua backend, checksums are recorded in mise.lock, along with attestation information if available.
In the AI era, this kind of defense in depth should matter more than ever. I hope this helps someone.
References
- GitHub Actions上で証明書を付与するサプライチェーン構築 | おそらくはそれさえも平凡な日々
- Artifact attestations - GitHub Docs
- Verifying the integrity of a release - GitHub Docs
- Threats & mitigations - SLSA
Mentions
project.pbxprojのコンフリクトをTuist導入で構造的になくす 私の所属している株式会社DIGITALIOでは、ポイントメディアサイトECナビを運営しており、 ECナビアンケートアプリ というモバイルアプリも展開しています。 本記事では、iOSアプリの並行開発で問題になっていた project.pbxproj のコンフリクトを、Tuist導入によって構造的になくした話をお伝えします。 tuist/tuist TL;DR 想定読者: iOSアプリの開発で project.pbxproj をGit管理している方 Tuist: Swiftで書いた定義ファイルからXcodeプロジェクトを生成するCLIツール 定義ファイルから project.pbxproj を毎回生成できるので、生成物をGit管理から除外できた project.pbxprojをGit管理することの難しさ 本アプリは、複数人で並行開発を行っています。Xcodeプロジェクトの実体は ECNaviEnqueteApp.xcodeproj/project.pbxproj という単一のテキストファイルです。ファイルの追加や設定変更のたびに、Xcode経由でこれを書き換える必要がありました。 私たちのチームは project.pbxproj をGit管理していたため、複数のブランチで同時にファイル追加や設定変更が発生すると、このファイルはコンフリクトしました。特に並行開発が増えた時期には、複数の行にまたがるコンフリクトの解消に手間がかかっていました。 PR1「project.pbxprojにファイルを追加してmasterにマージした!」 PR2「project.pbxprojにファイルを追加してmasterにマージするぞ!」 │ ▼ _人人人人人人人人人_ > コンフリクト!! <  ̄Y^Y^Y^Y^Y^Y^Y^Y^ ̄ <<<<<<< HEAD (PR1がpbxprojに追加した内容) ======= (PR2がpbxprojに追加した内容) >>>>>>> PR2 (;´Д`) 手作業でdiffを解読するしかない… また、実装ファイルだけをXcodeプロジェクトに追加し、テストファイルの追加を忘れることもありました。ビルドターゲットに含まれないテストは実行されず、CIも通るため、マージ後に追加漏れが発覚します。 これらの問題に加えて、Xcode上のGUI操作でプロジェクトを管理する方式には、次の2つの課題もありました。 設定変更がXcode上のGUI操作に依存するため、手順が煩雑になる project.pbxproj は人間にもAIエージェントにも読み解きにくく、変更の意図を追いにくい これらの課題を根本からなくすため、定義ファイルから project.pbxproj をCLIで自動生成する運用への切り替えを検討しました。生成物をGit管理から除外できるため、 project.pbxproj に起因するPR間のコンフリクトをなくせます。 XcodeGen・Tuist・Bazelの比較 プロジェクト生成ツールとして候補に挙がったのはXcodeGen・Tuist・Bazelの3つです。選ぶ条件は次の3つでした。 定義ファイルの誤りを、型とコンパイラで防げる 既存のCocoaPods運用を維持したまま導入できる 既存のビルドシステムを置き換えない この条件で各ツールを比較しました。 観点 XcodeGen Tuist Bazel 定義形式 YAML Swift DSL Starlark 定義ファイルの型検査1 なし あり なし CocoaPods互換性2 ○ ○ △ ビルドシステムの置き換え なし なし あり 結果、Tuistを導入することにしました。TuistはSwiftで記述できるため、引数の型や構文の誤りを定義ファイルのコンパイル時に検出できます。 XcodeGenは、YAMLゆえの型安全性の低さが気になりました。Bazelはリモートキャッシュや再現可能なビルドの面で強力な一方、Xcodeのビルドシステムごと置き換える規模感が今回の目的には釣り合わないと判断しました。 ! 選定後に知ったことですが、XcodeGenとTuistはどちらも tuist/XcodeProj を利用しています。Xcodeプロジェクトの読み書きを担う層は共通のため、この層に限れば、どちらを選んでも継続性の懸念は小さいと考えています。 Tuist導入の進め方 導入は影響範囲を絞るため3段階のPRに分割しました。 miseによるツールのバージョン固定 miseは、Node.jsのnvmやasdfのように、ツールのバージョンをプロジェクト単位で固定できるツールです。 mise.jdx.dev Tuistはインストール方法にmiseを推奨している3ため、本アプリでもバージョン固定にmiseを採用しました。 まずは .mise.toml を導入し、開発者間でTuistのバージョン差異が生じないようにしました。CIでも jdx/mise-action を使い、ローカルと同じバージョンのTuistを利用しています。 [tools] tuist = "4.195.9" Project.swiftの定義とMakefile整備 次に、定義ファイルの追加と便利コマンドの整備を行い、自動生成の運用フローを整えました。 Project.swift 既存のビルドターゲット・スキーム・ビルドスクリプトを Project.swift で再現し、従来と同等のビルド動作を維持しました。 import ProjectDescription private let appDependencies: [TargetDependency] = [ .sdk(name: "AdSupport", type: .framework, status: .optional), ] private let appTarget = Target.target( name: "ECNaviEnqueteApp", destinations: .iOS, product: .app, deploymentTargets: .iOS("15.0"), sources: [ .glob( "ECNaviEnqueteApp/", // ... ), ], resources: [ "PrivacyInfo.xcprivacy", .glob(pattern: "ECNaviEnqueteApp/", excluding: commonResourceExclusions), ], dependencies: appDependencies, // ... ) let project = Project( name: "ECNaviEnqueteApp", targets: [appTarget, appDevTarget, testsTarget, previewTarget], schemes: [appScheme, appDevScheme, previewScheme, testsScheme], // ... ) ビルドターゲットのファイルはglobで指定するため、ファイルを追加しても定義ファイルの編集は不要です。 CocoaPodsの統合方法 本アプリは当時CocoaPodsで依存関係を管理しており、 pod install が生成済みのXcodeプロジェクトへビルドフェーズやxcconfig参照を書き込む構成でした。そのため、 tuist generate から pod install までを一括で実行する make sync-develop 等を Makefile に用意しました。ここで地味にハマったのが、 pod install の挙動です。 sync-develop: 環境ごとに差し替えるファイルを事前にコピー cp ECNaviEnqueteApp/Resource/GoogleService-Info-Develop.plist ECNaviEnqueteApp/Resource/GoogleService-Info.plist Xcodeプロジェクト生成と依存関係の解決 tuist generate --no-open bundle exec pod install CocoaPodsには、依存関係に変更がなければ、変更のないPodのターゲットの再生成とプロジェクトへの統合を省略する 「インクリメンタルインストール」 (Incremental Pod Installation)があります。この仕組みは、 Pods/.project_cache/ に保存した前回のキャッシュキーと、今回の依存関係やビルド設定から作ったキャッシュキーを比べて変更の有無を判断します。このときアプリ側のXcodeプロジェクトの状態は見ません4。本アプリの Podfile では、当初 :incremental_installation => true を指定して有効にしていました。 install! 'cocoapods', :generate_multiple_pod_projects => true, :incremental_installation => true ... 一方、Tuistは tuist generate のたびにXcodeプロジェクトを完全に再生成するため、直前にCocoaPodsが追加したビルドフェーズやxcconfig参照は消えます。 tuist generate 直後の pod install では、 Pods/ 側のキャッシュが残っているため変更なしと判断され、ビルドフェーズやxcconfig参照が書き込まれませんでした5。原因はインクリメンタルインストールの差分判定にあったため、 :incremental_installation => false で無効化したところ、毎回書き込まれるようになりました。 ! 何らかの理由でインクリメンタルインストールを true のまま維持する場合は、 bundle exec pod install --clean-install で同様に統合できます。 project.pbxprojなどをGit管理から除外 第2段階でプロジェクト生成が安定して行えることを確認できたため、最後に ECNaviEnqueteApp.xcodeproj/ と ECNaviEnqueteApp.xcworkspace/ を git rm --cached でGit管理から除外しました。 Tuist Derived/ ECNaviEnqueteApp.xcodeproj/ ECNaviEnqueteApp.xcworkspace/ これにより project.pbxproj に起因するコンフリクトは発生しなくなりました。 Project.swift の同時編集によるコンフリクトは残りますが、読みやすいSwiftファイルの差分になります。生成物が手元に存在しない状態になるため、チームへの周知とドキュメント整備も合わせて行いました。 環境構築手順をまとめた docs/setup.md に、プロジェクト生成と依存関係のインストールの手順を追加した AIエージェント向けの CLAUDE.md に、 make sync-develop が必要になるタイミングとコマンドを記載した ドキュメントの記載例## プロジェクト生成と依存関係のインストール ECNaviEnqueteApp.xcodeproj/ と ECNaviEnqueteApp.xcworkspace/ はTuistによって自動生成されるためGit管理から除外されており、以下のコマンドによって管理している。 | コマンド | 用途 | | --- | --- | | make sync-develop | 開発用の GoogleService-Info.plist を配置し、Tuistでプロジェクトを生成、CocoaPodsを統合する | | make sync-release | 本番用の GoogleService-Info.plist を配置し、Tuistでプロジェクトを生成、CocoaPodsを統合する | 以下のケースで make sync-develop の実行が必要: - git clone の直後 - git pull やブランチ切り替え( Project.swift やファイル構成に差分がある場合) - 依存関係の更新 - ファイルの追加・削除 make init は内部で sync-develop を呼び出すため、初回セットアップ時は make init を実行するだけでよい。 Xcodeを開いた状態で sync-* を実行した場合 Xcodeでプロジェクトを開いたまま make sync-* を実行すると、Xcodeプロジェクトが外部から書き換えられたことをXcodeが検知し、ダイアログが表示される。 「Use Version on Disk」を選択する。 make sync-* によって生成された最新のファイルがXcodeに反映される。 導入後の変化 記事の前半で挙げた4つの課題は、次のように変わりました。 課題 導入後 PR間のコンフリクト project.pbxproj 起因はなくなった ファイルの追加漏れ sources のglobに一任 GUI操作の煩雑さ Project.swift の編集と make sync-develop で完結 解読の容易性 定義ファイルの差分として追えるようになった 一方で、トレードオフもあります。 git clone 直後のほか、ブランチの切り替え、ファイルの追加・削除、依存関係の更新のたびに make sync-develop の実行が必要になりました。 また、実行のたびにXcodeプロジェクトを再生成する関係上、次の2点にも注意が必要になりました。 一時的に有効にしたい設定はXcode上のGUI操作で変更しますが、プロジェクトの再生成時に失われます。例えば、デバッグ用に追加した FIRDebugEnabled などの起動引数です。 Xcodeを開いたまま実行すると「The file "..." has been modified by another application」というダイアログが出ます。そのため、その都度Xcodeを再読み込みし、ディスク上の最新状態を反映する運用にしました。 コンフリクトの解消は内容を読み解く判断が必要でしたが、CLIで再生成する場合の結果は定義ファイルだけで決まり、失敗しても再実行で同じ状態に戻せます。この性質の違いから許容できると判断し、コマンドを Makefile にまとめ、手順をドキュメントに残しました。 まとめ project.pbxproj に起因するコンフリクトは、Tuist導入によって構造的になくせました。第1段階〜第3段階という小さなステップに分けたことで、各PRのレビューと動作確認の負荷を抑えられたのも良かった点です。 同様の課題を抱えている場合は、まず1つのビルドターゲットを Project.swift で再現し、生成物が既存の project.pbxproj と同等になることを確認してからGit管理から除外するのがおすすめです。除外した後は自動生成が通常の運用フローになるため、周知も忘れずに行いましょう。 脚注 ここでの型検査は、定義ファイルの型の誤りをコンパイル時に検出できることを指す。XcodeGenのYAMLとBazelのStarlarkは静的型を持たず、誤りは生成や評価の実行時に分かる。 ↩︎ XcodeGenとTuistはCocoaPodsを管理せず、プロジェクト生成後に pod install を実行して併用。Bazelには公式のCocoaPods統合がなく、 rules_ios やサードパーティ製のツールを使うか、Podごとにビルド設定やリソースの扱いを調整する必要がある。 ↩︎ Install Tuist · Guides · Tuist を参照。 ↩︎ 判定ロジックは project_cache_analyzer.rb と target_cache_key.rb にある。設計の背景は Phase 1 、 Phase 2 を参照。 ↩︎ Pods/.project_cache を退避すると統合が復活し、 Manifest.lock や Podfile.lock を退避しても復活しないことをCocoaPods 1.16.2で確認している。 ↩︎
It May Be a Good Idea to Verify CLI Binaries Used in GitHub Actions with Both SHA256 Checksums and Attestations --- title: "It May Be a Good Idea to Verify CLI Binaries Used in GitHub Actions with Both SHA256 Checksums and Attestations" published: true description: "Why I verify CLI binaries downloaded from GitHub Releases with both a SHA256 checksum and gh attestation verify, and how gh release verify-asset can help for immutable releases." tags: ["github", "githubactions", "security"] cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com canonical_url: masutaka.net --- In masutaka/actions, which I introduced in [2026-02-23-1], CLI binaries such as jactionlint are downloaded from each tool's GitHub Releases. The downloaded binaries are verified with both a SHA256 checksum and gh attestation verify. In this post, I'll explain how and why. Verifying with SHA256 Checksums and Attestations Here's how masutaka/actions verifies jactionlint: :link: .github/workflows/gh_action_lint.yml - name: Install jactionlint env: GH_TOKEN: ${{ github.token }} REPO: jdx/jactionlint ASSET: "*_linux_amd64.tar.gz" TAG: v2.0.2 ASSET_SHA256: 026bce6d0ef196f5b22bf43acb1348d47c5d0a5ae61d6ec3bdba1229a823161b SIGNER_WORKFLOW: jdx/jactionlint/.github/workflows/release.yaml EXECUTABLE: jactionlint run: | gh release download "$TAG" \ --repo "$REPO" \ --pattern "$ASSET" \ --output asset.tar.gz # 正規の release workflow がそのタグからビルドした成果物であることを検証する gh attestation verify asset.tar.gz \ --repo "$REPO" \ --signer-workflow "$SIGNER_WORKFLOW" \ --source-ref "refs/tags/$TAG" # ピンした時点から中身が変わっていないことを検証する echo "${ASSET_SHA256} asset.tar.gz" | sha256sum -c - tar -xzf asset.tar.gz "$EXECUTABLE" It downloads the asset from GitHub Releases and uses it only if it passes both gh attestation verify and sha256sum -c. --signer-workflow only checks the workflow path; it doesn't pin the ref or commit. So I use --source-ref to restrict it to artifacts built from the recorded tag. The attestations checked by gh attestation verify have to be generated by the publisher in their release workflow. For the publisher's side of the story, Songmu's post goes into detail: :link: GitHub Actions上で証明書を付与するサプライチェーン構築 | おそらくはそれさえも平凡な日々 After reading it, I also migrated my own github-nippou to actions/attest. By the way, --signer-workflow can point to a different repository than --repo. For example, ghalint is signed by suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml, a reusable workflow called from its release.yaml. Why Verify with Both? Because SHA256 checksums and gh attestation verify verify different things. - SHA256 checksum - Verifies that "the content hasn't changed since the checksum was recorded" - Even a binary built locally without going through CI will match if you record its value - gh attestation verify - Verifies that "the artifact was built from that tag by the legitimate release workflow" - However, unless it's an immutable release, tags can be moved. If an attacker gains write access, moves the tag to a tampered commit, and rebuilds, it still passes - It also passes if the artifact is rebuilt under the same tag and the asset is overwritten. For example, when a build-time dependency gets compromised later on I couldn't find any official recommendation to use both, but I did find some supporting information. The GitHub Docs page on Artifact attestations says that "artifact attestations are not a guarantee that an artifact is secure." There's also SLSA (Supply-chain Levels for Software Artifacts), an OpenSSF framework that defines levels of software supply chain security. The build provenance checked by gh attestation verify is also in the SLSA format. SLSA's Threats & mitigations states that the threat of compromised build-time dependencies is out of SLSA's scope. The mitigation it suggests is pinning dependencies by digest: > You may be able to mitigate this threat by pinning your build dependencies, preferably by digest rather than version number. This is aimed at publishers, but I think users pinning by checksum follows the same idea. That said, checksums only protect against replacement after you've recorded them. If a dependency was already compromised at the time of the first release, both the checksum and the attestation will pass. Personally, I'd be happy if gh attestation verify could also check that the artifact matches a recorded checksum, but for now I'm verifying with two separate methods. If There's No Build Provenance, Release Attestations May Help What follows is something I learned while writing this post. I've only tried it locally and don't use it in masutaka/actions. gh attestation verify verifies build provenance, so it can't be used unless the publisher generates it. For example, tfupdate, a tool that bulk-updates version constraints for Terraform core and providers, doesn't generate build provenance, as you can see from the repository's Attestations page. So gh attestation verify fails with a 404: $ gh attestation verify asset.tar.gz --repo minamijoyo/tfupdate Error: HTTP 404: Not Found (https://api.github.com/repos/minamijoyo/tfupdate/attestations/sha256:559bc7c6c15496682a3207cbdc4e6327de252f5f1a4348f5098027d6bcc4ebe2?per_page=30&predicate_type=https%3A%2F%2Fslsa.dev%2Fprovenance%2Fv1) Even so, if the repository has immutable releases enabled, you can use release attestations. GitHub generates them automatically for releases published after the setting is turned on. A release attestation lets you verify that "this asset was included in the release for this tag." It doesn't show up on the Attestations page, so check the release page to see whether it's an immutable release. tfupdate v0.10.2 is an immutable release, so it can be verified with gh release verify-asset: $ gh release verify-asset v0.10.2 asset.tar.gz --repo minamijoyo/tfupdate Calculated digest for asset.tar.gz: sha256:559bc7c6c15496682a3207cbdc4e6327de252f5f1a4348f5098027d6bcc4ebe2 Resolved tag v0.10.2 to sha1:9d61d78d73e1325629e46c9e92d713ab258b50cd Loaded attestation from GitHub API ✓ Verification succeeded! asset.tar.gz is present in release v0.10.2 This method is described in the GitHub Docs page Verifying the integrity of a release. A release attestation doesn't tell you whether the artifact was built by the legitimate workflow. Still, it does confirm that the downloaded file is the same as the asset in that release. With an immutable release, tags can't be moved and assets can't be overwritten, so it also closes the tag-moving loophole I mentioned in "Why Verify with Both?" Conclusion It seems like a good idea to verify CLI binaries used in GitHub Actions and elsewhere with both SHA256 checksums and gh attestation verify. The checksum confirms the content hasn't changed, and the attestation confirms it was built by the legitimate release workflow. Even if the publisher doesn't generate build provenance, gh release verify-asset looks usable for immutable releases. I've only tried this one locally. Here's a comparison of the three methods: | Verification | What it confirms | What the publisher needs | |---|---|---| | SHA256 checksum | The content hasn't changed since the checksum was recorded | Nothing (a checksums.txt lets you cross-check the value you record) | | gh attestation verify | It was built from that tag by the legitimate release workflow | Generating build provenance | | gh release verify-asset | It was included in the release for that tag | Enabling immutable releases | Another option is to install via mise-action. For tools using the aqua backend, checksums are recorded in mise.lock, along with attestation information if available. In the AI era, this kind of defense in depth should matter more than ever. I hope this helps someone. References - GitHub Actions上で証明書を付与するサプライチェーン構築 | おそらくはそれさえも平凡な日々 - Artifact attestations - GitHub Docs - Verifying the integrity of a release - GitHub Docs - Threats & mitigations - SLSA