(60分で)Next.js アプリを Firebase Hosting/GitHub Actions でインターネットに公開するぞぃ！

# 概要

Next.js で作るアプリをサクッとデプロイしたいとなった時、どんな方法があるかと見ていたところ、Firebase Hosting によるちょうど良さそうなのがあったのでその紹介となる。
Firebase はいくつかのサービスを使用したことがあるが、Hosting は初めてだったので少し試行錯誤した点も併せて記載する。

> Firebase Hostingは、ウェブアプリ、静的および動的コンテンツ、マイクロサービスに高速で安全なホスティングを提供します。

# 方針

このページを元に作業を進めていくことになる。

https://firebase.google.com/docs/hosting/nextjs?hl=ja

`フレームワーク対応のホスティング` という比較的新しい?機能を使うことになり、使用に当たっては以下注意されたし。

:::note warn
フレームワーク対応のホスティングは、初期のパブリック プレビューです。これは、下位互換性のない方法で機能が変更される可能性があることを意味します。プレビュー リリースは、SLA または非推奨ポリシーの対象ではなく、限定的なサポートまたはサポートを受けられない場合があります。
:::


# やったこと

## アプリの用意

Next.js のアプリを作成する。
今回は内容には重きを置いていないため、一旦 `create-next-app` などで作成されるデフォルトのものをほぼそのまま使用する。

```sh
npx create-next-app@latest
```

## GCP コンソールにて、プロジェクトの作成

Firebase Hosting では、GCP と連携してさまざまなサービスが利用可能になる。
GCP 上では、アプリをデプロイするプロジェクトが必要となるため、あらかじめコンソール上で作成しておく。

https://cloud.google.com/resource-manager/docs/creating-managing-projects?hl=ja

## ローカル環境からのデプロイ

### 準備

ローカルの CLI から Firebase の機能を呼び出せるようにする。

```
npm install -g firebase-tools@latest
```

続いて、上述した `フレームワーク対応のホスティング` を有効化する。

```
firebase experiments:enable webframeworks
```

Hosting サービスを初期化する。

```
firebase init hosting
```

この時、いくつか質問されるので、適宜選択していく。
その中で、「Detected an existing Next.js codebase in the current directory, should we use this?」と聞かれるので、Next.js を選択することで、このフレームワークに対応したセットアップが行われる。
また、「Set up automatic builds and deploys with GitHub?」という質問に対しては、Yes を選択することで、GitHub と Firebase が連携される。その後のオプションで、デプロイ前に実行するスクリプトの指定や、 PR マージ時の自動デプロイなどを設定できる。

### デプロイ

アプリをプロジェクトにデプロイするコマンドを叩く。

```sh
firebase deploy
```

この時に複数のエラーが発生したため、その内容と対応を記載する。(お手持ちの環境によって異なるだろう)

#### Node バージョンエラー

```
Error: The frameworks awareness feature requires Node.JS >= 16 and npm >= 8 in order to work correctly, due to some of the downstream dependencies. Please upgrade your version of Node.JS, reinstall firebase-tools, and give it another go.
```
 
→ Node バージョンまたは npm バージョンが古いため、アップグレードする。

##### Node バージョン管理に `nvm` を使っている場合のやり方例:

```
# 最新の安定バージョンをインストール
nvm install stable --latest-npm
// v18.13.0

nvm use v18.13.0
```

#### ロール関係のエラー

```
Unable to retrieve the repository metadata for projects/<project>/locations/<region>/repositories/gcf-artifacts. Ensure that the Cloud Functions service account has 'artifactregistry.repositories.list' and 'artifactregistry.repositories.get' permissions. You can add the permissions by granting the role 'roles/artifactregistry.reader'.
```

→ パーミッションが不足しているので、`artifactregistry.reader` というロールを付与する。

GCP コンソール > IAM ページで、`appspot.gserviceaccount.com` のサフィックスがついたサービスアカウントに対して、`Artifact Registry Reader` のロールを追加する

#### 不明なデプロイエラー

```json
"stateMessages":[
	{
		"severity":"ERROR",
		"type":"CloudRunServiceNotFound",
		"message":"Cloud Run service projects/<project>/locations/<region>/services/<service> for the function was not found. The function will not work correctly. Please redeploy."
	}
]
```

→ なぜかエラーとなるので、GCP コンソール > Cloud Functions のページで、該当の Function を削除し、再度デプロイする。

## 確認

以上で、デプロイ作業は完了である。
URL にアクセス(デフォルトでは`https://<projectId>.web.app/`)して、無事ページが表示されることを確認する。


# GitHub Actions によるデプロイ(30 more minutes :clock12:)

GitHub によるソースコードのバージョン管理と Firebase へのデプロイをシンクさせるには、Firebase CLI によるデプロイでは難しい。
そこで、GitHub Actions を利用することで、**特定の tag が打たれたコミットが push されたときに Firebase へのデプロイを行う**ようにした。

## GitHub ワークフローの設定

プロジェクトルートに、`.github/workflows/**.yml` を作成し、「どんなトリガーが来たらどんなアクションを起こすか」を定義する。

ワークフローについては、以下が詳しい:

https://docs.github.com/ja/actions/using-workflows/events-that-trigger-workflows#%E7%89%B9%E5%AE%9A%E3%81%AE%E3%82%BF%E3%82%B0%E3%81%AE%E3%83%97%E3%83%83%E3%82%B7%E3%83%A5%E3%81%8C%E7%99%BA%E7%94%9F%E3%81%97%E3%81%9F%E5%A0%B4%E5%90%88%E3%81%AB%E3%81%AE%E3%81%BF%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%82%92%E5%AE%9F%E8%A1%8C%E3%81%99%E3%82%8B

今回は以下のような設定とした(`projectId` の部分は適宜読み替えてほしい):

```yaml
on:
  push:
    tags:
      - 'release-**' # ①

jobs:
  build_and_preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: npm ci && npm run build
      - run: npm install -g firebase-tools && firebase experiments:enable webframeworks # ②
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: '${{ secrets.GITHUB_TOKEN }}'
          firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_<PROJECTID> }}'
          projectId: <projectId> # ③
          channelId: live # ④
```

ポイントを簡単に説明すると

- ①: 先頭に「**release-**」 が含まれる tag が付けられたコミットが対象であること。
    - 「**」は任意の文字列とマッチする。「release-hoge」, 「release-20230123」など
- ②: ローカル環境で使用した Firebase-tools と、`フレームワーク対応のホスティング` の有効化が必要であること。
- ③: `projectId` を指定すること。
- ④: `channelId` を指定すること。

特に ④ についてはエラーが何度か出たため試行錯誤した。
元々 Firebase hosting の初期化によって自動生成された **.github/workflows/\*\*.yml** では、`channelId` のキー自体が存在せず、そのままだと **「channelID is currently required」** のエラーが出た。[この記事](https://github.com/marketplace/actions/deploy-to-firebase-hosting#channelid-string)によると、`channelId` はブランクにすることで自動生成されるらしいので、`channelId: ""` で試したところ、**「Web frameworks with dynamic content do not yet support deploying to preview channels」** と怒られたため、最終的に `live` を設定した。

### その他の注意ポイント

#### Firebase Hosting へのデプロイ設定関係のエラー

```
Must supply a \"public\" directory or at least one rewrite or redirect in each \"hosting\" config.
```

これに対しては、Firebase の設定ファイル(`firebase.json`) にて一旦 **適当なリダイレクトを設定する** ことでしのいだ。

```diff_json
...
{
  "hosting": {
    "source": ".",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
+   "redirects": [
+     {
+       "source": "/foo",
+       "destination": "/bar",
+       "type": 301
+     }
+   ]
  }
}
```

#### ロール関係のエラー1

```
HTTP Error: 403, Permission denied to get service [runtimeconfig.googleapis.com]\nHelp Token: xxxx
```

→ GCP コンソール > IAM ページで、GitHub Actions のサービスアカウント(`github-action-` のプレフィックスがついた)に対して、`Artifact Registry Reader` のロールを追加する.


#### ロール関係のエラー2

```
Cloud Functions deployment requires the Cloud Build API to be enabled. The current credentials do not have permission to enable APIs for project ... see Firebase FAQs:\nhttps://firebase.google.com/support/faq#functions-runtime\n
```

→ GCP コンソール > IAM ページで、GitHub Actions のサービスアカウント(`github-action-` のプレフィックスがついた)に対して、`Cloud Build Service Account` のロールを追加する。

#### ロール関係のエラー3

```
HTTP Error: 403, Permission denied to get service [artifactregistry.googleapis.com]\nHelp Token: xxxx”
```

→ GCP コンソール > IAM ページで、GitHub Actions のサービスアカウント(`github-action-` のプレフィックスがついた)に対して、`Basic > Editor` のロールを追加する。


## いざ Git push

### 通常のやり方

git commit した後に、git tag \<tag\> && git push origin \<tag\>

### tag 名を特定の形式 & インクリメントされるような形でデプロイに持っていく

例えば、**「release-YYYY-MM-DD.v」** という形式で tag を打ってデプロイしていきたいとする。
- `YYYY`: 西暦
- `MM`: 月
- `DD`: 日
- `v`: その日の中のバージョン。**複数ある場合には １ ずつインクリメントされていく**


この時のスクリプトを書いたので、参考にしていただければ幸いである。
`release.sh` の中で `getNewTag.js` を呼んでいる。

```release.sh
#!/bin/sh
# 存在する tag をリストする
tags=$(git tag --list "release-*")

BASEDIR=$(dirname $0)

# 適切な tag を取得する
NEWTAG=$(node "$BASEDIR/getNewTag.js" ${tags[@]})

git tag $NEWTAG
git push origin $NEWTAG
```

```getNewTag.js
if (process.argv.length <= 2) {
  console.log('no arrays inputed, avorted.');
  return;
}

const tags = process.argv.slice(2);

const PREFIX = 'release-';// tag's format: vDDDD-MM-DD.x(x:the day when this release is done)

// tag　のリストを年月日で降順ソートして、初めの要素が最新のものとなる
const tagsSorted = tags.sort((a, b) => {
  const [date_a, t_a] = a.slice(PREFIX.length).split('.');
  const [date_b, t_b] = b.slice(PREFIX.length).split('.');
  const [y_a, m_a, d_a] = date_a.split('-').map(Number);
  const [y_b, m_b, d_b] = date_b.split('-').map(Number);

  if (y_a != y_b) return y_b - y_a;
  if (m_a != m_b) return m_b - m_a;
  if (d_a != d_b) return d_b - d_a;
  if (t_a != t_b) return t_b - t_a;
});

const [latest, t] = tagsSorted[0].slice(PREFIX.length).replace(/-\d+$/, '').split('.');
const [y1, m1, d1] = latest.split('-');

const [y2, m2, d2] = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }).split(' ')[0].split('/');

if (y1 === y2 && m1 === m2 && d1 === d2) {
  // 同じ日なら tag バージョン部分をインクリメント
  return `${PREFIX}${y2}-${m2}-${d2}.${Number(t) + 1}`;
} else {
  // 初めての日なら、tag バージョンは 1
  return `${PREFIX}${y2}-${m2}-${d2}.1`;
}
```

スクリプトを叩けば、適切な tag がコミットに打たれて、それを remote に push し、GitHub Actions が作動して Firebase Hosting へのデプロイが始まる。

```sh
./release.sh
```

## 確認

GitHub Actions による ワークフローの進捗は、GitHub の `Actions` タブから確認することができる。
問題なく緑のチェックマークが表示されたら、再度URL にアクセス(デフォルトでは`https://<projectId>.web.app/`)して、無事ページが表示されることを確認する。


## 追記 2023/6/17

前回最後のデプロイから2ヶ月ほど間を開けて、最近(6月上旬ごろ)上記で紹介した方法でデプロイを試みたところ、以下のエラーが発生した。

```
Error: ENOENT: no such file or directory, open '/<path_to_project>/.next/server/client-reference-manifest.js'
```

ググっても有用な情報が見つからず、とりあえずまっさらなプロジェクトをもう一度作成して試した。

```sh
npx create-next-app@latest
npm install firebase-tools@latest
...
firebase deploy
```

すると、デプロイは成功した。

そこで、このまっさらなプロジェクトと先ほどまでエラーがあったプロジェクトの違いを見てみたところ、Next.js のバージョンに違いがあることが判明した。

```
旧: 13.1.4
新: 13.4.6
```

上記エラーの内容的に、Next.js のビルドファイルが関係していそうだったため、バージョンを最新にして、再度デプロイを試みた。

```sh
npm install next@latest
...
firebase deploy
```

すると、今度はエラーなく、デプロイが完了した！
...ように思われたが、何分待っても画面上であるべき更新が確認されないため、GCP 上の `Cloud Functions > Function details` のページを確認すると、Errors の箇所にエラーメッセージが表示されていた。

```
Error: Process exited with code 1
    at process.<anonymous> (/layers/google.nodejs.functions-framework/functions-framework/node_modules/@google-cloud/functions-framework/build/src/invoker.js:92:22)
    at process.emit (node:events:513:28)
    at process.emit (node:domain:489:12)
    at process.exit (node:internal/process/per_thread:190:15)
    at ChildProcess.<anonymous> (/workspace/node_modules/next/dist/server/lib/render-server-standalone.js:42:25)
    at ChildProcess.emit (node:events:525:35)
    at ChildProcess.emit (node:domain:552:15)
    at Process.ChildProcess._handle.onexit (node:internal/child_process:293:12)
    at Process.callbackTrampoline (node:internal/async_hooks:130:17)
```

これに関して、やはりググっても全く有用情報がなかったため、仕方なく2ヶ月間のコミットログを遡り、どこからエラーが起こり得ていたのかを調べる戦法にでた。(特定のコミットに戻し、Next.js のバージョンを最新にしてビルド/デプロイ というのを境目が判明するまで繰り返した)

その結果、Next.js 13 の新機能である **app directory を有効化していると、上記最後のエラーが発生することが判明した。**
とりあえず app directory を無効化することで、デプロイは無事完了したのである。
Next.js 13 の目玉機能である app directory を止めるのは残念だったが、本当にこれが問題なのかを含めて、また時間があれば調査したい。 =終=