📁

モバイルアプリ開発における共通ファイルを集中管理する

に公開

はじめに

複数のモバイルアプリを開発・運用していると、各リポジトリで共通の設定ファイルやテンプレートを管理する必要が出てきます。
しかし、これらを個別に管理していると、更新のたびに複数のリポジトリへ同一の変更を加える必要があり、運用コストの増大と更新漏れのリスクが生じます。
本記事では、リポジトリを跨いで共通ファイルを集中管理し、GitHub Actionsを活用して配信を自動化した取り組みを紹介します。

背景

ウェルスナビのモバイルアプリチームは、昨年9月の時点では少人数体制であり、iOS/Android各プラットフォームごとに担当を分け開発を進めているため、各プラットフォーム間での開発プロセスに差異がありました。
また、事業成長に伴う施策増加やセキュリティ要件の高度化により、これまで以上の開発スピードが求められるようになっています。

現在モバイルチーム体制拡大を図っており、各プラットフォーム間でのプロセス改善及びリファクタリングに注力できる体制にまで成長してきたので、まずは各プラットフォーム間で扱う「各種共通設定ファイル」について、今後起こるであろう組織変更や施策が増えても開発スピードを損なわないために、更新や運用方法の改善を実施しました。

具体的な課題

ウェルスナビでは、私たちはメインプロダクトである「ウェルスナビ」[1][2]と、おつりで積立投資ができる「マメタス」[3][4]という2つのプロダクトを提供しており、それぞれiOS/Android版があるため、計4つのリポジトリを運用しています。

各リポジトリには、以下のような共通ファイルが存在します。

  • .github/CODEOWNERS (コードオーナー設定)
  • .github/pull_request_template.md (PRテンプレート)
  • .editorconfig (共通のコードスタイル設定)
  • .ruby-version (Rubyのバージョン指定)
  • script/create-pr (PR作成用スクリプト)

従来は、これらを「1つのリポジトリで更新したら、手動で他リポジトリへコピーする」という運用をしており、共通の管理基盤がなかったため以下の課題に直面していました。

  • 分散管理による齟齬: チーム全体での開発ルールの反映に時間差が生じる
  • 更新漏れのリスク: 特定のリポジトリで行った改善が他に反映されず、人為的ミスを排除できない
  • 運用コストの増大: 全リポジトリへの適用完了までのリードタイムが長く、承認者の負荷も高い

今後のさらなるマルチプロダクト化を見据え、これらの共通ファイルを一元管理する仕組みの導入を決定しました。

解決アプローチ

実装内容

1. 共通ファイル管理リポジトリの新設
一元管理用の専用リポジトリ(mobile-common-template)を作成しました。
2. GitHub Actionsによる自動配信
管理リポジトリの更新をトリガーに、各アプリリポジトリへ変更を同期するワークフローを構築しました。

技術的な詳細

リポジトリの構成

管理リポジトリ内では、ターゲットに合わせて以下のディレクトリ構造を採用しています。

  • common/: 全プラットフォーム共通
  • ios/: iOSリポジトリ専用
  • android/: Androidリポジトリ専用

また、各ディレクトリには同期先を制御する設定ファイルを用意しています。
画像は一例です。
ディレクトリ構造例

ios/.config.yml(例)
os: ios
repos:
  - foo-ios
  - bar-ios
reviewers:
  - alpha
  - bravo
  - charlie

同期ワークフローについて

同期スクリプトは、サブディレクトリを含むすべてのファイルを探索できる glob の扱いやすさから、Rubyで実装しています。
このワークフローは以下の流れで動作します。

  1. 管理リポジトリへのPushをトリガーに起動
  2. .config.yml を読み込み、同期対象リポジトリとレビュワー情報を取得
  3. 各リポジトリに対して以下を実行
    1. 直前コミットとの差分を取得
    2. common または ios/android 配下に追加/変更されたファイルがあれば対象リポジトリにアップロード
      この時、 GitHub Repository Contents API[5] を使用することで対象同期先のリポジトリ情報を取得することなくファイルの追加/更新ができます
一部コードを抜粋したものがこちら
update_repo.rb
def update_repo(repo, config:)
  os_directory = config['os']
  reviewers = config['reviewers']
  diff = Git.fetch_diff.filter { |d| ['A', 'M', 'R'].include? d[:status] }.map { |d| d[:file] }
  if diff.include? "#{os_directory}/.config.yml"
    repo.update_reviewers(reviewers)
  end
  repo.update_changed_files_in_directory('common', diff: diff, exclude: ['.config.yml'])
  repo.update_changed_files_in_directory(os_directory, diff: diff, exclude: ['.config.yml'])
end
repo.rb
class Repo
  def update_changed_files_in_directory(directory, diff:, exclude: [])
    require 'pathname'
    directory = Pathname(directory)
    diff = diff.map { |d| Pathname(d) }
    raise "`#{directory}` is not a directory" unless File.directory?(directory)
    Logger.info "Updating files in `#{directory}` for repo: `#{get_repo_name}`"
    Dir.glob('**/*', File::FNM_DOTMATCH, base: directory)
      .filter { |file| !exclude.include?(file) }
      .map { |file| directory + file }
      .filter { |file| file.file? && diff.include?(file) }
      .each do |file|
        content = File.read(file.to_s)
        file_path_in_repo = file.relative_path_from(directory)
        file_info = fetch_file(file_path_in_repo.to_s)
        if file_info.nil?
          Logger.warning "File not found: `#{file_path_in_repo}` in repo: `#{get_repo_name}`. Creating new file..."
          upload_new_file(file_path: file_path_in_repo.to_s, content: content, message: "[skip ci] Create #{file_path_in_repo}")
        else
          Logger.info "Updating `#{file_path_in_repo}` in `#{get_repo_name}`..."
          update_file(file_info, message: "[skip ci] Update #{file_path_in_repo}", content: content)
        end
      end
  end

  # 新しいファイルをアップロードする
  def upload_new_file(file_path:, content:, message:)
    require 'json'
    encoded_content = Base64.strict_encode64(content)
    `gh api -X PUT /repos/#{get_repo_name}/contents/#{file_path} -f message="#{message}" -f "content=#{encoded_content}"`
  end

  # 既に存在するファイルを更新する
  def update_file(file_info, message:, content:)
    require 'json'
    file_path = file_info['path'].strip
    sha = file_info['sha'].strip
    encoded_content = Base64.strict_encode64(content)
    return Logger.warning "No changes detected in `#{file_path}`." if file_info['content'].gsub(/\n/, '') == encoded_content
    `gh api -X PUT /repos/#{get_repo_name}/contents/#{file_path} -f message="#{message}" -f "content=#{encoded_content}" -f "sha=#{sha}"`
  end
end

結果

✅ 導入して良かった点

  • 管理コストの劇的な削減: 1箇所の修正で4リポジトリ(今後増えても同様)に自動反映されるため、横展開が容易に
  • 反映漏れの撲滅: 自動化によりヒューマンエラーを抑止、リポジトリごとの差分を解消
  • 変更の透明化: 「共通設定がどう変わったか」が管理リポジトリの履歴を見るだけで把握できるように

⚠️ 今後の課題

  • 実行権限の制約: Contents API ではファイルの実行権限(chmod +x)を付与してアップロードできません。新規スクリプト追加時のみ、初回だけ手動での権限付与が必要です。
  • コミットの粒度: 1ファイルごとに1コミットが作成されるため、大量更新時はログが混雑します。実運用上は大きな問題になっていませんが、一括更新APIの登場に期待しています。

さいごに

複数のリポジトリにまたがる共通ファイルの管理は、組織が拡大するほど避けられない課題です。
GitHub Actions と Repository Contents API を組み合わせることで、開発者の手を煩わせることなく、クリーンで一貫性のある開発環境を維持できるようになりました。

今後は集中管理する対象をさらに広げ、マルチプロダクト化も見据えた開発者体験の整備を目指していきます。

脚注
  1. https://apps.apple.com/jp/app/id1181342875 ↩︎

  2. https://play.google.com/store/apps/details?id=com.wealthnavi.amami ↩︎

  3. https://apps.apple.com/jp/app/id1236011920 ↩︎

  4. https://play.google.com/store/apps/details?id=com.wealthnavi.mametasu ↩︎

  5. https://docs.github.com/ja/rest/repos/contents ↩︎

WealthNavi Engineering Blog

Discussion