mdBookドキュメントをCloudflare Pagesへデプロイする

# はじめに

mdBookのドキュメントをGitHub ActionsでCloudflare Pagesへデプロイしてみました。
比較的かんたんに作れたと思うので、入門用によいかもしれません。
専用のドキュメント管理サイトを持ちたい、という方にもおすすめします。

:::note warn
本記事は創作レベルであり、本来のmdBook機能を発揮できていない部分がありますので、参考程度にご覧ください。
例）メタ情報の一部（タイトル）が表示されない
:::

## 使用ツールについて

[mdBook](https://github.com/rust-lang/mdBook)はドキュメント生成ツールです。Markdownファイルからリッチな画面を作成してくれます。似たツールに[MkDocs](https://www.mkdocs.org/)があります。
所有元がrust-langということもあり、Rustを扱う方ならみたことない人はいないほどRust関連のドキュメント管理に利用されています。

- 例
  - https://doc.rust-lang.org/book/
  - https://doc.rust-lang.org/cargo/
  - https://rust-lang.github.io/mdBook/

[Cloudflare Pages](https://developers.cloudflare.com/pages)は無料プランで、500ビルド/月まで可能であり、最大25MiBのファイルを20,000までホストできます。以前から気になっていたので使ってみました。
[Limits · Cloudflare Pages docs](https://developers.cloudflare.com/pages/platform/limits/)


# 準備編
まずはツールを使ってみてイメージを持つことからはじめます。

## mdbookを使ってみる

### インストール
ローカルでは`cargo install mdbook`でインストールしましたが、バイナリが配布されているのでcargoを使わずともインストールできます (後述の`.github/workflows/reusable_build.yml`をご参照ください)。
参考：https://rust-lang.github.io/mdBook/guide/installation.html

### ドキュメント生成
`mdbook init foo --title foo --ignore git`とすると下記の構成でファイルが生成されます。
実際に編集するのはsrc/配下のMarkdownファイルで、ビルド資材がbook/配下に生成されるというシンプルな構成となってます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127889/61045436-a5ed-1df1-1f59-732e0c78a57f.png)


まだ使い慣れていないですが、`mdbook serve`したまま`SUMMARY.md`に見出しを作ると自動的にMarkdownファイルを生成してくれるのは良さそうでした。

例）`SUMMARY.md`に見出しを作ったときのファイル生成

```diff
  # Summary

  - [Chapter 1](./chapter_1.md)
+ - [Chapter 2](./chapter_2.md)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127889/12ab07a3-a7a3-cec4-1da3-85c3f463e9be.png)


## Cloudflare Pagesを使ってみる
公式ドキュメントを参照しながら進めていきましょう。
[Get started guide · Cloudflare Pages docs](https://developers.cloudflare.com/pages/get-started/guide/)

### リポジトリを準備する
リポジトリを新規作成し、適当なHtmlを用意します。

```bash
gh repo create my-project --public --clone
cd my-project
touch index.html
code index.html
```

### Cloudflare Pagesへ手動デプロイする

Cloudflareへアカウント登録した上で、[Cloudflare dashboard](https://dash.cloudflare.com/) からPagesを新規作成します。
先ほど作成した`my-project`リポジトリを指定してビルド設定へ進めていきましょう。
今回はmdBookを使うだけなので、Framework presetはNoneを選択しました。
最後まで終わるとデプロイできると思います。

## デプロイ自動化の準備をする
[Use Direct Upload with continuous integration · Cloudflare Pages docs](https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/#use-github-actions)
CloudFlareからワークフローが提供されており、GitHub Actionsでやります。
https://github.com/cloudflare/pages-action

### Cloudflare API Tokenを生成する
[Create API token · Cloudflare Fundamentals docs](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)
デプロイに必要なAPI Tokenを生成しましょう。
cloudflare/pages-actionワークフローは[Wrangler](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler)というCLIを使用してデプロイしてます。

[Cloudflare dashboard](https://dash.cloudflare.com/) からAPI Tokenを生成します。


設定例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127889/5abd5ff4-f2d6-f0ce-5e05-972a6ab089b6.png)



> Create an API token in the Cloudflare dashboard with the "Cloudflare Pages — Edit" permission.
> https://github.com/cloudflare/pages-action

Permissionは`Cloudflare Pages:Edit`だけでOKです。
作成したAPI Tokenはすぐに下記手順でsecretへ登録しておきましょう。後から参照できないです。

### GitHub secretを登録する
[GitHub Actions でのシークレットの使用 - GitHub Docs](https://docs.github.com/ja/actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository)
リポジトリへsecretを登録しましょう。

- `CLOUDFLARE_API_TOKEN`
  - API Token
- `CLOUDFLARE_ACCOUNT_ID`
  - Account ID
    - 参考： [Find zone and account IDs · Cloudflare Fundamentals docs](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/)



# 本編

最終的なディレクトリ構成です。

`.github/workflows/deploy.yml`はCloudflare Pagesへのデプロイを行います。
`.github/workflows/reusable_build.yml`は`generate-book.py`を呼び出し、ビルド資材をまとめます。
`generate-book.py`はディレクトリ構成を考慮して適切に`mdbook build`します。
`book/`と`src/`は`generate-book.py`の成果物であり、ローカル検証に利用します。
`foo/`と`home/`は`mdbook init`で作成したディレクトリです。`home/`はトップページに表示する特別な画面と位置づけており、`foo/`は特定の話題に関するドキュメントとお考えください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127889/04b60614-7beb-3454-ca5b-02c11c136253.png)


最終的にはビルド成果物`book/`をCloudflare Pagesへデプロイするイメージです。
ローカル環境では下記により`book/`のビルド結果を確認できます。
```bash
python3 generate-book.py home
python3 generate-book.py foo
```

## `generate-book.py`
GitHub hosted runnerではpythonを実行できます。
pythonで書いた経緯は下記を参考にしたからです。はじめて書いたのでボロボロかもしれません。
https://github.com/rust-lang/rfcs

引数を受け取り、`src/`配下へ一時的にMarkdownファイルを配置します。
`mdbook build`で`src/`配下を元に`book/`へビルド成果物を出力します。
このとき、
　`home/`の場合、`book/`配下にビルド成果物が格納されます。
　`foo/`の場合、`book/foo/`配下にビルド成果物が格納されます。

```python
#!/usr/bin/env python3

import os
import shutil
import subprocess
import sys

# mdbookがsrc/配下をみてbuildするので、一時的に資材をsrc/配下へ配置しbuildする
# build資材はbook/配下にまとまっていく想定
def main(book_title):
    init_dir('src')
    gen_src(book_title)

    # homeはトップのためbook/直下に配置する
    if book_title == 'home':
        result = subprocess.call(['mdbook', 'build', '-d', 'book'])
    else:
        result = subprocess.call(['mdbook', 'build', '-d', f'book/{book_title}'])
    # TODO: test
    if result != 0:
        print("Error: An error occurred during the execution of mdbook build.", file=sys.stderr)
        sys.exit(1)

def init_dir(dir):
    if os.path.exists(dir):
        # Clear out src to remove stale links in case you switch branches.
        shutil.rmtree(dir)
    os.mkdir(dir)

# src/配下のファイルを生成
def gen_src(input_dir):
    # src/配下のMarkdownファイル
    entries = [e for e in os.scandir(f'{input_dir}/src') if e.name.endswith('.md')]
    for entry in entries:
        symlink(f'../{entry.path}', f'src/{entry.name}')
    # src/images/配下の画像ファイル
    if os.path.exists(f'{input_dir}/src/images'):
      entries = [e for e in os.scandir(f'{input_dir}/src/images') if e.name.endswith(('.png', '.svg'))]
      if len(entries) > 0:
        init_dir('src/images')
        for entry in entries:
            symlink(f'../../{entry.path}', f'src/images/{entry.name}')

def symlink(src, dst):
    if not os.path.exists(dst):
        os.symlink(src, dst)

if __name__ == '__main__':
    # 第一引数としてbook_titleを取得できなければ終了する
    book_title = sys.argv[1] if len(sys.argv) > 1 else ""
    if book_title == '':
        print("Error: No argument provided. Please specify the book title.", file=sys.stderr)
        sys.exit(1)
    main(book_title)

```



## `.github/workflows/reusable_build.yml`

ドキュメントを増やす場合に記述量を減らすため、reusable-workflowとしています（参考：[ワークフローの再利用 - GitHub Docs](https://docs.github.com/ja/actions/using-workflows/reusing-workflows)）

mdBookをインストールします。
該当ディレクトリの`src/`配下の全ファイルを基にしたハッシュをキーとして、キャッシュを確認します。
なおキャッシュは7日で消えるらしい（参考：[依存関係をキャッシュしてワークフローのスピードを上げる - GitHub Docs](https://docs.github.com/ja/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy)）ので、更新頻度によっては意味がないかもしれません。
キャッシュがない場合は、上述の`generate-book.py`でビルドしキャッシュを保存します。
最後にArtifactをアップロードします。upload-artifactは`name`が重複した場合に競合エラーとなるので名前をつけてあげます。

```yml
name: Reusable Build Workflow

on:
  workflow_call:
    inputs:
      dir-name:
        description: 'Directory name used in executing mdbook build.'
        required: true
        type: string

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Install mdbook
        run: |
          mkdir mdbook
          # for updates see: https://github.com/rust-lang/mdBook/tags
          curl -Lf https://github.com/rust-lang/mdBook/releases/download/v0.4.37/mdbook-v0.4.37-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=./mdbook
          echo `pwd`/mdbook >> $GITHUB_PATH
      # To avoid useless build
      - name: Check Cache ${{ inputs.dir-name }}
        # can't set dynamic id. To avoid conflict, each reusable flow should be runned synchronously...
        id:  cache_check
        uses: actions/cache/restore@v4
        with:
          path: ./book
          key: ${{ runner.os }}-${{hashfiles(format('./{0}/src/**', inputs.dir-name))}}
      - name: Generate Book ${{ inputs.dir-name }}
        if : steps.cache_check.outputs.cache-hit != 'true'
        run: |
          ./generate-book.py ${{ inputs.dir-name }}
      - name: Save Cache ${{ inputs.dir-name }}
        if : steps.cache_check.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with:
          path: ./book
          key: ${{ runner.os }}-${{hashfiles(format('./{0}/src/**', inputs.dir-name))}}
      - name: Upload Artifact
        uses: actions/upload-artifact@v4
        with:
          # name isn't allowed duplicated.
          name: ${{format('artifact_{0}', inputs.dir-name)}}
          path: ./book
```

ちなみにワークフロー上で保存したキャッシュの確認は下記で行えました。

```bash
gh extension install actions/gh-actions-cache
gh actions-cache list --limit 10
```
参考：

https://zenn.dev/hankei6km/articles/manage-cache-in-github-actions



## `.github/workflows/deploy.yml`

上述の`.github/workflows/reusable_build.yml`をビルド対象毎に呼び出します。
依存関係をつけて直列にしている理由は、`.github/workflows/reusable_build.yml`の`Check Cache ${{ inputs.dir-name }}` stepのidに区別がつかないためです。job.idやstep.idには動的な値を埋め込むことができませんでした。（別の方法を模索中です）
download-artifactの際、取得したartifactをマージしたものを`book/`配下へ展開しています (参考：https://github.com/actions/download-artifact/blob/main/docs/MIGRATION.md#multiple-uploads-to-the-same-named-artifact)。
`book/`配下をCoudflare Pagesへデプロイします。


```yml
name: Deploy
on:
  push:
    branches:
      - main

jobs:
  build_base:
    uses: ./.github/workflows/reusable_build.yml
    with:
      dir-name: home
  build_foo:
    needs: build_base
    uses: ./.github/workflows/reusable_build.yml
    with:
      dir-name: foo

  deploy:
    needs: [build_base, build_foo]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    steps:
      # https://github.com/actions/download-artifact/blob/main/docs/MIGRATION.md#multiple-uploads-to-the-same-named-artifact
      - name: Download Artifact
        uses: actions/download-artifact@v4
        with:
          path: ./book
          pattern: artifact_*
          merge-multiple: true
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: my-project
          directory: book
          branch: main
```


## 動作確認

mainブランチをpushしてActionsが成功すればOKです。
Actions結果画面にデフォルトでdeploy summaryが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127889/7679991e-8b8c-0ea4-987a-249698d6bf3c.png)



Preview URLへアクセスすると`home/`の内容がトップページとして表示されます。
`foo/`配下は`/foo`へアクセスすると確認できるので、リンクを作っておきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127889/1711b93c-3200-e8b8-cf66-ea93dae9d2cd.png)



# おわりに

はじめて触れた内容が多く、ツッコミどころ満載な気がします。
Cloudflareに手を出せたので、無料枠の範囲で色々手を出してみたいです。