GitHubで非公開リポジトリの一部をOSS公開するためのマージ戦略
はじめに
こんにちは。Acompany CTOの田中(@tkrk_p)です。本記事は「#アカンクリスマスアドベントカレンダー2023」13日目の記事となります。
弊社Acompanyは次世代暗号化技術として注目されている秘密計算のOSS『QuickMPC』を公開しています。QuickMPCは以下のように20行足らずのPythonスクリプトで情報理論的安全性を有する暗号化手法によって秘匿化しながらデータ連携が可能な実用性の高い秘密計算エンジンです。)
https://github.com/acompany-develop/QuickMPC/blob/main/demo/client_demo/README-ja.md
このQuickMPCは元々社内でClosedに開発されていたソフトウェアでしたが、MPCを社会普及させるために一部機能をOSSとして公開した形になります。
このように社内開発していたソフトウェアをOSS化するケースはよくあると思いますが、弊社ではOSS公開を進めていく上でGitHub上でのソースコードのバージョン管理方法に課題が生じました。
簡単に説明すると、下記のようにpublicなpub_repoでOSSとしてコードベースを管理しつつ、privateなpriv_repoでpriv_dir/のような一部の公開したくないコードベースを管理する上で、GitHubのfork機能を使わずにDRYにバージョン管理したいという課題になります。
pub_repo
└── dir/...
---
priv_repo
├── dir/...
└── priv_dir/...
備忘録として、その課題の解決方法について調査した際のメモを残しておきます。
※ 結論だけ知りたい方は末尾の まとめ セクションに飛んでください。
二重管理による管理コストの爆発
QuickMPCは当初GitHubのprivateリポジトリとして管理されていました。
まず初めにこのリポジトリをそのままpublicリポジトリに変更して公開しようと考えたのですが、コードベースの一部に機密性の高いロジックやコミットメッセージがコードベースからの分離が難しい形で含まれていたため、新たにOSSとして公開可能な部分のみを切り出したpublicリポジトリを新規作成して公開することに決めました。
しかし、単一のソフトウェアを2つの独立したリポジトリで管理しようとすると、コードベースの共通部分に二重管理コストが発生してしまいます。
例えば、以下の様なpriv_repoとpub_repoが存在するとします。
pub_repo
└── file
---
priv_repo
├── file
└── priv_file
このとき、pub_repoとpriv_repoが独立で管理されていた場合、dir/とfileはどちらのリポジトリにも存在するため、変更が生じるたびに両リポジトリで別々に差分のcommitをしてPRを出すことになります。これは非常にコストのかかる作業になります。
forkは可視性を変更できない
OSS開発の場合は、GitHubのfork機能を使ってコードベースを共有しつつ差分のみを別リポジトリで開発・管理することが可能ですが、fork機能はコードベースだけでなく可視性の設定も共有されてしまうため、publicリポジトリはpublicリポジトリとして、privateリポジトリはprivateリポジトリとしてforkされ、その可視性を変更することはできません。
フォークとは、元の "上流" リポジトリとコードと可視性の設定を共有する新しいリポジトリです。
https://docs.github.com/ja/get-started/quickstart/fork-a-repo#about-forks
おそらくライセンスの関係でこのような制約になっているのだと思いますが、今回の様なシチュエーションではforkが使えないため別の方法でpublicリポジトリとprivateリポジトリでコードベースを共有する必要がありました。
GitHubでpublicとprivateでコードベースを共有する方法
このようなシチュエーションへの対応策として検討を行った結果、以下の2つの方法が浮上しました。
- git submodule add <pub_repo>
- (推奨) git remote add upstream <pub_repo>
それぞれ説明していきます。
1. git submodule add <pub_repo>
1つめの方法として、privateリポジトリにpublicリポジトリの特定のコミットへの参照をsubmoduleとして管理する方法が挙げられます。理由は後述しますがこの方法は非推奨です。
作業手順
例えば、以下の様なディレクトリ構成のpub_repoがあったとします。
pub_repo
└── dir/...
この時、以下のコマンドによりpriv_repoにてsubmoduleとしてpub_repoを追加します。
$ git submodule add <pub_repo>
これにより、以下の様なディレクトリ構成で管理することができます。
priv_repo
├── priv_dir/...
├── .gitmodules
└── pub_repo @ aba0cb2
└── dir/...
ただし、submoduleは特定のコミットを指しているためsubmoduleのリポジトリで更新が行われても自動的には反映されません。更新を反映する際は以下のコマンドを実行する必要があります。
$ git submodule update --remote
使い所
この方法は以下の様にデメリットが多く、個人的には特に理由がなければ非推奨です。
- submoduleの操作はgit submoduleコマンドのサブコマンドとして定義されているため、開発者はsubmoduleを常に意識しながら開発する必要がある。
- リポジトリをcloneするたびにgit submodule init(or git clone --recruisve)を実行したり、submoduleが更新されるたびに各開発者が手元でgit submodule updateを実行する必要がある。
- CICDにおいてもsubmoduleの扱いにケアする必要がある
- submoduleはバージョンがcommit hashで表示されることになるため、submoduleが更新された時やsubmoduleでコンフリクトが生じた際にヒューマンエラーが生じやすくなる。
- priv_repoの方でPRを出した際にはpub_repoの差分はcommit hashの差分としてしか表示されず非常にわかりにくい。
- pub_repoとpriv_repoを同時に編集して開発する場合、以下の様なステップを踏む必要があり非常に大変。
- [pub_repo]commit → push → PR → merge[priv_repo]submoduleのcommit hash変更 → commit → push → PR → merge
- submoduleはサブディレクトリに一対一対応するため、どうしてもpub_repoとpriv_repoでrootの階層がずれてしまい、階層を合わせたい場合は別途shellscriptなどでシンボリックリンクを貼るなどする必要がある。
2. (推奨) git remote add upstream <pub_repo>
2つめの方法として、privateリポジトリにpublicリポジトリを参照するremoteを登録して管理する方法が挙げられます。こちらが推奨の方法です。
作業手順
例えば、以下の様なディレクトリ構成のpub_repoがあったとします。
pub_repo
└── dir/...
この時、priv_repoにて以下のコマンドを実行します。
$ git remote add upstream <pub_repo>
このコマンド操作の後にremoteを確認すると以下の様にoriginとupstreamの2つのリポジトリが登録されていることがわかります。
$ git remote -v
origin <priv_repo> (fetch)
origin <priv_repo> (push)
upstream <pub_repo> (fetch)
upstream <pub_repo> (push)
これにより、gitコマンドのrepository部分にoriginを指定すればpriv_repoへ、upstreamを指定すればpub_repoへと参照を切り替えながらgit操作を行うことができるようになります。
その後、pub_repoのmainブランチが更新されるたびに以下のコマンドを実行することで、PRの形でpriv_repoのmainブランチへ反映することが可能です。(--allow-unrelated-historiesは初回以降不要)
# localとremote originの同期
$ git fetch origin
# localにupdate用ブランチを用意してremote upstreamを反映してremote originにpush
$ git checkout main
$ git checkout -b update-<version>
$ git fetch upstream main
$ git merge upstream/main --allow-unrelated-histories
$ git push origin HEAD
なお、merge時にコンフリクトが生じた際はupdate-<version>ブランチで解消してcommitしておきます。
この一連の操作により、以下の様にpriv_repoを綺麗に、かつDRY原則に基づいて共通のコードベースを管理することが可能です。
priv_repo
├── dir/...
└── priv_dir/...
ちなみに裏技として、以下の様にsubtreeコマンドを利用することでも代替可能です。
# priv_repo以下にpub_repo/をサブプロジェクトとして追加
$ git subtree add --prefix=pub_repo upstream main
# pub_repo/以下にpriv_dir/...を移植
$ mv ./priv_dir pub_repo/
これにより余分に一階層増えてしまいますが、以下の様に同様にDRYに管理可能です。
priv_repo
└── pub_repo
├── priv_dir/...
└── dir/...
pub_repoをpriv_repoに反映させるにはコマンドを実行すれば良いです。
# localとremote originの同期
$ git fetch origin
# localにupdate用ブランチを用意してremote upstreamを反映してremote originにpush
$ git checkout main
$ git checkout -b update-<version>
$ git subtree pull --prefix=pub_repo upstream main
$ git push origin HEAD
使い所
この方法は、fork時にも使われ最もベーシックな方法だと思われます。標準的な操作しか行わないため比較的覚えやすいです。
ただし、以下の様なデメリットがあるため、なるべくpub_repoの更新を自動で反映させられるように自動化が必要になります。
-
pub_repoとpriv_repoを同時に編集して開発する場合、以下の様なステップを踏む必要があり非常に大変。
- [pub_repo]commit → push → PR → merge[priv_repo]checkout -b update-<version> → merge upstream/main (→ コンフリクト解消 → commit) → PR → merge
- git log(graph)に2リポジトリ分のブランチが表示されるため慣れるまで認知負荷が高い。
また、pub_repoとpriv_repoを同時に編集しないポリシーで運用すれば、upstreamはpub_repoの更新をpriv_repoに反映させる操作を行う開発者のみが参照すれば良いため、非公開部分の開発を行う開発者は特にupstreamの存在を意識することなく開発することが可能というメリットがあります。
まとめ
GitHubにおいて、下記のようにpub_repoとpriv_repoでdir/を共有管理する方法についてまとめた。
pub_repo
└── dir/...
---
priv_repo
├── dir/...
└── priv_dir/...
推奨プラクティスは以下の方法。
#priv_repoのremoteにpub_repoをupstreamとして登録
$ git remote add upstream <pub_repo>
# localとremote originの同期
$ git fetch origin
# localにupdate用ブランチを用意してremote upstreamを反映してremote originにpush
$ git checkout main
$ git checkout -b update-<version>
$ git fetch upstream main
$ git merge upstream/main --allow-unrelated-histories
$ git push origin HEAD
おまけ(教訓)
環境依存の情報のような非公開にしたい情報はそもそもコードベースに含めるべきではない。
ノウハウとなり得るようなロジックは含めざるを得ない場合もあるが、密結合にならないようなアーキテクチャ・IFを設計するべき。
最後に宣伝
Acompanyではエンジニアを積極採用しています!もし、ちょっとでも気になった方はリンク先か田中のX(Twitter)のDMから連絡お願いします!
それではみなさん、Happy Hacking😎!!
Discussion