🔧

CMakeのFecthContentを自動で更新する

に公開

TL;DR

  • CMakeで依存関係を自動的にダウンロードできるけど、自動更新するツールはないよ!
  • CMakeLists.txtを解析して自動バージョンアップを実装したよ!

はじめに

C/C++ではコンパイラに依存しないビルドツールとしてCMakeが広く使われています。また、CMakeを使えば依存関係をきちんと記述して、ライブラリを簡単にリンクできます。
CMakeにはfetchContentという仕組みがあり、CMakeLists.txtに依存関係を記述し、依存するライブラリを自動的にダウンロードできます。しかし、CMakeのfetchContentは他のプログラミング言語のように中央集権的なリポジトリがないため、自動で更新する便利なツールはなく、GIT_TAGオプションで指定するGitのコミットやタグを書き換えるくらいしかできません。
また、リポジトリごとにコミットやタグとリリースされたバージョンの関連も異なるため、更新するときの課題となります。

そこで、CMakeLists.txtを解析して自動で更新するPythonスクリプトを書きました。
具体的には、依存関係を抽出し、そのgithubやgitlabなどのリポジトリのAPIを叩き、最新のリリースに対応するタグを取得してCMakeLists.txtを更新するライブラリを作りました。

コード

import re
import requests
from dataclasses import dataclass


@dataclass
class CMakeDependency:
    name: str
    repo: str
    tag: str

    def __latest_from_gh(self, repo_name) -> str:
        url_release = f"https://api.github.com/repos/{repo_name}/releases/latest"
        response_release = requests.get(url_release)
        if response_release.status_code == 200:
            result_release = response_release.json()
            return result_release["tag_name"]
        else:
            # No releases found, get the default branch
            url_repo = f"https://api.github.com/repos/{repo_name}"
            response_repo = requests.get(url_repo)
            response_repo.raise_for_status()
            default_branch = response_repo.json()["default_branch"]
            url_default_branch = (
                f"https://api.github.com/repos/{repo_name}/branches/{default_branch}"
            )
            response_default_branch = requests.get(url_default_branch)
            response_default_branch.raise_for_status()
            return response_default_branch.json()["commit"]["sha"]

    def __latest_from_gl(self, repo_name) -> str:
        encoded_repo = repo_name.replace("/", "%2F")
        release_url = f"https://gitlab.com/api/v4/projects/{encoded_repo}/releases"
        response_release = requests.get(release_url)
        if response_release.status_code == 200:
            result = response_release.json()
            return result[0]["tag_name"]
        elif response_release.status_code == 404:
            # No releases found, get the default branch
            repo_url = f"https://gitlab.com/{encoded_repo}"
            response_repo = requests.get(repo_url)
            response_repo.raise_for_status()

            default_branch = response_repo.json()["default_branch"]
            default_branch_url = f"https://gitlab.com/api/v4/projects/{encoded_repo}/repository/branches/{default_branch}"
            response_default_branch = requests.get(default_branch_url)
            response_default_branch.raise_for_status()
            return response_default_branch.json()["commit"]["id"]
        else:
            response_release.raise_for_status()

    def latest(self) -> str:
        gh_pattern = r"https://github.com/(\S+).git"
        gl_pattern = r"https://gitlab.com/(\S+).git"
        if re.match(gh_pattern, self.repo):
            repo_name = re.sub(gh_pattern, r"\1", self.repo)
            return self.__latest_from_gh(repo_name)
        elif re.match(gl_pattern, self.repo):
            repo_name = re.sub(gl_pattern, r"\1", self.repo)
            return self.__latest_from_gl(repo_name)
        else:
            raise ValueError(f"Unknown repository: {self.repo}")


if __name__ == "__main__":
    print("Checking CMake dependencies...")

    with open("CMakeLists.txt", "r+") as f:
        cmake_src = f.read()

        fetched_deps_pattern = r"^FetchContent_Declare\(\s*(\S+)\s*GIT_REPOSITORY\s+(\S+)\s+GIT_TAG\s+(\S+)\s*\)"
        fetched_deps = re.findall(fetched_deps_pattern, cmake_src, re.MULTILINE)
        fetched_deps = [
            CMakeDependency(name, repo, tag) for name, repo, tag in fetched_deps
        ]

        for dep in fetched_deps:
            latest = dep.latest()
            if dep.tag == latest:
                continue
            print(f"Updating {dep.name} ({dep.repo}): {dep.tag} -> {latest}")
            dep_old_pattern = f"FetchContent_Declare\\(\\s*{dep.name}\\s*GIT_REPOSITORY\\s+{dep.repo}\\s+GIT_TAG\\s+{dep.tag}\\s*\\)"
            dep_old = re.findall(dep_old_pattern, cmake_src, re.MULTILINE)
            if len(dep_old) == 0:
                raise ValueError(f"Dependency {dep.name} not found in CMakeLists.txt")
            elif len(dep_old) > 1:
                raise ValueError(
                    f"Multiple definitions of {dep.name} found in CMakeLists.txt"
                )
            else:
                dep_old = dep_old[0]
                dep_new = dep_old.replace(dep.tag, latest)
                cmake_src = re.sub(dep_old_pattern, dep_new, cmake_src)

        f.seek(0)
        f.write(cmake_src)
        f.truncate()
        print("All CMake dependencies updated.")

このスクリプトは以下の構造となっています。

  1. 依存関係の記述FetchContent_Declareを正規表現を使って検索し、、各依存ライブラリの名前、リポジトリURL、タグを抽出してCMakeDependencyクラスのインスタンスにまとめます。
  2. リポジトリのURLに基づき、GitHubの場合は/releases/latestエンドポイントから最新リリースのタグを、リリース情報がない場合はデフォルトブランチの最新コミットを取得します。GitLabの場合も同様にAPIで最新リリース情報またはデフォルトブランチの最新コミットIDを取得しています。
  3. 各依存ライブラリについて、現在指定されているタグと最新のタグを比較し、異なる場合は古いタグを最新のものに置換してファイルを更新します。

おわりに

CMakeで依存関係を記述するfetchContentを使った依存関係を自動的に更新するスクリプトを紹介しました。このスクリプトを実行するだけで、依存関係を更新できるので、人力の更新作業を削減できます。

現状ではgithubとgitlabに対応しており、全ての依存関係を最新に更新しますが、今後は他のリポジトリへの対応や更新対象を限定するオプションの追加を検討しています。

では、よきCMakeライフを!

Discussion