Lernaを使ってFirebase環境のためのモノレポ環境一式をカッコよく構築する

Firebaseで必要な環境設定をまとめていたら思ったより長くなってしまいました。
ClientもServerもtypescriptで書いたり共通のlint設定などで管理する場合モノレポ形式でやると良いです。
ビルド環境からテスト環境、CIの環境まで解説します。

完成後のレポジトリはこちらです:https://github.com/kyasbal-1994/firebase-monorepo


# イケてるモノレポ環境構築

Node.jsでサーバーサイドを書いたり、複数のクライアントプロジェクトがあると以下のような問題に直面すると思います。

- 共用できるコードなのに別々に書いてる
- 一度持ってきた共用コードなのに古くなってる
- 同じようなプロジェクトなのにデプロイの設定とかいろいろがオリジナリティ溢れる
- チーム内で分担によって知らないコードが増えてしまう

このような問題を解決すべく、[Lerna](https://github.com/lerna/lerna)を用いたプロジェクトを作成します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/41402/a5a5176c-57e4-077d-4b6a-9ffd70acc5c1.png)

先ほどのような実例としての問題だけではなく、これによって作れる複数レポジトリを一つにまとめて全体を管理するやり方では以下のような欠点、利点があるとされます。

> **Pros**
> パッケージ間が密に連携していることによる問題を解決できる
> どちらのリポジトリにIssue登録するか問題
> 1つ問題を修正するのに、複数リポジトリを修正してそれぞれ別にプルリク送らないといけない問題
> packageをまたいだテストを行いやすくなる
> 各パッケージで共通のnpm moduleはトップレベルのpackage.jsonおよびnode_modulesで管理でき、ストレージ節約になる
> バージョン番号を統一できる
> git submoduleで管理するのと比べるとgit submodule updateを意識する必要がなくなるので環境構築が楽になる(個人差ありそう)
> **Cons**
> リポジトリがでかくなる
> エディタの動作が重くなるかもしれない
> lernaの学習コストがかかる
> lernaの扱いが面倒な部分がある
> [lernaを使ってnpmプロジェクトをモノレポ化する](https://92thunder.hatenablog.com/entry/2018/05/30/222440)より引用

## 今回作ってみるプロジェクト構成

- クライアントサイド
  - webpack
  - Typescript
  - babel
  - React.js
- サーバーサイド
  - Node.js
  - Firebase function

さらにそれぞれ`staging`環境と`production`環境を分けた実践的なプロジェクト構成を作ってみるものとし、それぞれCircleCIを用いてデプロイされるような環境までを作成します。

# プロジェクト構造作成編

## モノレポの作成 / サブプロジェクトの作成

```bash
$ mkdir firebase-monorepo
$ cd firebase-monorepo
$ npm install lerna --save-dev
$ npx lerna init
```

最後の`lerna init`をすると以下のようなログが出力され、monorepoを構成する複数のプロジェクトが入る`packages`フォルダが生成されます。

```
lerna notice cli v3.4.3
lerna info Initializing Git repository
lerna info Creating package.json
lerna info Creating lerna.json
lerna info Creating packages directory
lerna success Initialized Lerna files
```

さらに、いい感じにgitignoreも作っておきます。lernaでは各サブプロジェクトに`node_modules`フォルダができるのでこれらもignoreしておきます。

```bash
$ gibo dump Node MacOS Windows Linux > .gitignore
$ echo "**/node_modules" >> .gitignore
$ echo "**/lib" >> .gitignore
$ echo "**/dist" >> .gitignore
```

以下のように`lerna create`を用いてサブプロジェクトを作成します。createの後ろにつくプロジェクト名は必ずしも`@XXX`によって先行する必要はありませんが、統一のために共通のパッケージの名前空間(`@XXX`の部分のこと)を持つと良いでしょう。

```bash
$ lerna create @firebase-monorepo/server
```

すると以下のようにいろいろ聞かれ(npm initと基本は同じはずです)、サブプロジェクトが`packages/server`として生成されます。

```bash
package name: (@firebase-monorepo/server)
version: (0.0.0)
description:
keywords:
homepage:
license: (ISC)
entry point: (lib/server.js) lib/index.js
git repository:
```

今後の進行のため`entrypoint`は`lib/index.js`を指定します。
また、`package.json`に`typings`という属性を追加し`lib/index.d.ts`を指定します。

他のサブプロジェクトも作っておきましょう。作るサブプロジェクトは今作った`@firebase-monorepo/server`を含めて以下の3つです。

- `@firebase-monorepo/server` ・・・ Firebase functionにpublishされるサーバ本体
- `@firebase-monorepo/client` ・・・ Firebase hostingにpublishされるクライアント
- `@firebase-monorepo/api-schema`・・・クライアントとサーバが共有するAPIのスキーマ

```bash
$ lerna create @firebase-monorepo/client
$ lerna create @firebase-monorepo/api-schema
```

## プロジェクトの参照関係を追加する

プロジェクト名を見るとわかる通り、`api-schema`は`server`と`client`の両方から参照されています。
以下のようにしてserver,clientからapi-schemaへの参照関係を作ります。

```bash
$ lerna add @firebase-monorepo/api-schema --scope @firebase-monorepo/server
$ lerna add @firebase-monorepo/api-schema --scope @firebase-monorepo/client
```

各サブプロジェクトの中の`node_modules`がエイリアスを貼ってくれたのがわかると思います。

# ビルド環境作成編

## Typescriptの環境をセットアップする

今回のプロジェクトではtypescriptはどのプロジェクトでも参照します。ルート階層で`lerna add`を用いて外部のプロジェクトも追加することができます。

```bash
$ lerna add typescript
```

ルート階層に以下のような内容のtsconfig.jsonを作成します。

```json
{
    "compilerOptions": {
        "module": "commonjs",
        "declaration": true,
        "noImplicitAny": false,
        "removeComments": true,
        "noLib": false,
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
        "target": "es6",
        "sourceMap": true,
        "lib": [
            "es6"
        ]
    },
    "exclude": [
        "node_modules"
    ]
}
```

これらのtsconfig.jsonを参照するように`packages/server/tsconfig.json`,`packages/client/tsconfig.json`,`packages/api-schema/tsconfig.json`の3つを同じように以下のように作ります。

```json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir":"./lib",
    "declarationDir": "./lib",
    "rootDir": "./src",
    "baseUrl": "./"
  },
  "include": ["./src"]
}
```

サブプロジェクトそれぞれの`package.json`にbuildコマンドを追加します。

```json
"scripts":{
  "build":"tsc"
}
```


`packages/api-schema/src/index.ts`に以下のように記述しましょう。

```typescript
export class CounterResult{
  public count:number;
}
```

`lerna run build`コマンドでプロジェクトそれぞれでビルドを走らせます。

```bash
$ lerna run build
```

他にファイルがないのでエラーが一部出ると思いますが、これでそれぞれのプロジェクトに`npm run build`しているのと同じ状況になります。

試しに`server.ts`でこのファイルを参照してみましょう。

```typescript
import {CounterResult} from "@firebase-monorepo/api-schema";

CounterResult.name
```

しっかりとTypescriptの型が効いていると思います。

## Prettierを設定する

まずはビルド環境よりlint環境です。コードを書き始めてからでは遅いので先に設定します。
全プロジェクトで同じコーディング規約を定めることとしましょう。

`lerna add prettier --dev`をして[Prettier](https://github.com/prettier/prettier)を追加します。

```bash
$ lerna add prettier --dev
```

ルートディレクトリに`.prettierrc.json`を設置して、各プロジェクトに`.prettierignore`を設置します。


**.prettierignore.json**

```json
**/package-lock.json
*.scss.d.ts
**/lib/
**/node_modules/
```

**.prettierrc.json**

```json
{
  "semi": false,
  "singleQuote": true
}

```

各プロジェクトのpackage.jsonのscriptsにlintを追加します。

```json
"scripts":{
   "lint":"lint": "prettier --list-different './src/**/*.{js,ts,tsx,scss,json}'",
   "lint:format": "prettier --write './src/**/*.{js,ts,tsx,scss,json}'"
}
```

同様にして`lerna run lint:format`などで動作をチェックすることが可能です。

## Huskeyでgitコマンドのフックをしてlint-stagedでformatを強制する

```bash
$ npm install huskey --dev
$ lerna add lint-staged
```

huskeyだけはルートにだけ存在すればいいのでnpm installをする。

huskeyでprecommitをフックしてコミット前にlintしてstageすることを強制する。
ルートのpackage.jsonに以下のような設定を追加する。

```bash
  "husky": {
    "hooks": {
      "pre-commit": "lerna exec npx lint-staged"
    }
  }
```

これにより、`pre-commit`時に全てのサブプロジェクトで`lint-staged`が実行される。これによってgitのステージングに上がっているファイルにprettierをかけることができる。

各プロジェクトのpackage.jsonには以下のように`lint-staged`の設定を置く。

```json

  "lint-staged": {
    "./src/**/*.{js,ts,tsx,scss,json}": [
      "npm run lint:format",
      "git add"
    ]
  }
```

## クライアント側のビルドの設定をする

必要そうなmoduleをどんどん入れます。lernaだと複数個を一気に追加できないので今回だけclientのフォルダに入ります。まずはバンドリング周りの設定から。今回はwebpackでreactの環境をビルドすることを想定します。


```bash
$ cd ./packages/client
$ npm i webpack webpack-cli webpack-dev-server  webpack-merge html-webpack-plugin \
 mini-css-extract-plugin uglifyjs-webpack-plugin \
 optimize-css-assets-webpack-plugin babel-loader typings-for-css-modules-loader \
 sass-loader postcss-loader style-loader \
 @babel/core @babel/plugin-proposal-class-properties \
 @babel/preset-typescript @babel/plugin-proposal-decorators @babel/polyfill \
 @babel/preset-env @babel/preset-react cpx --save-dev
```

各moduleの説明

* `webpack`,`webpack-cli` : webpackを使うために最小限必要なもの
* `webpack-dev-server` : 開発用のサーバを開いてくれるやつ
* `webpack-merge` : webpackのコンフィグをマージするやつ
* webpackプラグインたち
    * `html-webpack-plugin` : ビルド時に同時にhtmlを生成するプラグイン
    * `mini-css-extract-plugin` : 一つのcssにまとめ直してくれるプラグイン
    * `uglifyjs-webpack-plugin` : jsのminifyのプラグイン
    * `optimize-css-assets-webpack-plugin` : CSSの最適化を行なってくれるプラグイン
* webpackのローダーたち
    * `babel-loader` : Babelを通すためのローダ。今回はtsのコンパイルにも用いる。
    * `typings-for-css-modules-loader` : CSS modulesの型定義を生成するローダ
    * `scss-loader` : SCSSのコンパイルをするローダ
    * `postcss-loader` : CSSのAutoprefixをするため
    * `style-loader` : デバッグ時にスタイルを埋め込むためのローダ
* Babel系
    * `@babel/core` : Babel本体
    * `babel-preset-env` : Babelの基本的なプリセットを選んでくれるやつ
    * `@babel/preset-typescript` : Typescriptのビルドを行なってくれるプリセット
    * `@babel/preset-react` : Reactのjsx記法のビルドを行なってくれるプリセット
    * `@babel/polyfill` : 古いブラウザサポート用のpolyfill
    * `@babel/plugin-proposal-decorators` : デコレータ記法を使えるようにするためのplugin
    * `@babel/plugin-proposal-class-properties` : React周りでよく見るやつ。staticメンバを追加できるようになる
* その他
    * `cpx` : コピーに使う。あとで出てきます。

これらをインストールした上で以下の3つのwebpack.config.jsによって成り立たせます。

* webpack.config.js
* webpack.dev.config.js
* webpack.prod.config.js

**packages/client/webpack.config.js**

```javascript
const path = require("path");
const webpack = require("webpack");
const HtmlPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    index: ["@babel/polyfill", "./src/index.tsx"]
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].[hash].js"
  },
  plugins: [
    new HtmlPlugin({
      filename: "index.html",
      template: "template/index.html",
      minify: {
        removeComments: true,
        collapseWhitespace: true
      }
    })
  ],
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          {
            loader: "babel-loader"
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: [".js", ".json", ".ts", ".tsx"]
  }
};
```

**webpack.dev.config.js**

```javascript
const webpack = require("webpack");
const common = require("./webpack.config");
const merge = require("webpack-merge");
const devSpecific = {
  mode: "development",
  devtool: "eval-source-map",
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("development")
    })
  ],
  module: {
    rules: [
      {
        test: /\.s?css$/,
        use: [
          {
            loader: "style-loader"
          },
          {
            loader: "typings-for-css-modules-loader",
            options: {
              modules: true,
              sass: true,
              namedExport: true,
              camelCase: true,
              localIdentName: '[path][name]__[local]--[hash:base64:5]'
            }
          },
          {
            loader: "sass-loader"
          }
        ]
      }
    ]
  }
};
module.exports = merge(common, devSpecific)
```

**webpack.prod.config.js**

```javascript
const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const common = require("./webpack.config");
const merge = require("webpack-merge");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const OptimizeCssAssetsPlugin = require("optimize-css-assets-webpack-plugin");

const prodSpecific = {
  mode: "production",
  devtool: "source-map",
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourceMap: false,
        extractComments: true
      }),
      new OptimizeCssAssetsPlugin()
    ]
  },
    plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("production")
    }),
    new MiniCssExtractPlugin(),
    new webpack.optimize.OccurrenceOrderPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.s?css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          },
          {
            loader: "typings-for-css-modules-loader",
            options: {
              modules: true,
              sass: true,
              namedExport: true,
              camelCase: true,
              localIdentName: '[hash:base64:4]'
            }
          },
          {
            loader:"postcss-loader"
          },
          {
            loader: "sass-loader"
          }
        ]
      }
    ]
  }
};
module.exports = merge(common, prodSpecific);
```

**注釈**

* entry.outputを`filename: "[name].[hash].js"`のように書いておくとjsにhashが追加された形で生成されるためにキャッシュされなくなります

このままだとstaticなファイルの置き場所に困るので`packages/client/static`を作成します。その上でビルド後にstaticのファイルをdistにコピーされるように設定するため、`cpx`を入れます。

htmlのテンプレートとして`packages/client/templates/index.html`を作成します。内容は以下のようなもので、ビルドされたjsへの参照は自動的に追加されるようになっています。

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>ものれぽ</title>
</head>

<body>
    <div id="app"></div>
</body>

</html>
````

```bash
lerna add cpx --scope @firebase-monorepo/client
```

client側のpackage.jsonにビルド、デバッグ用のscriptsを追加します。

```json
"build": "webpack --config ./webpack.prod.config.js && cpx 'static/**/*' dist --verbose",
"build:dev": "webpack --config ./webpack.dev.config.js && cpx 'static/**/*' dist --verbose",
"start": "webpack-dev-server --watch --config ./webpack.dev.config.js --content-base static/"
```

さて、ここで`lerna run build`でクライアントも一緒にビルドされる様子をみたいところなのですが、先ほどnpm installで一気にインストールしてしまったのでこのままだとサブプロジェクトへの参照がうまく機能しません。

```bash
lerna clean
lerna bootstrap
```

を行うと`lerna run build`でクライアントがビルドできるようになります。

## サーバー側のビルド環境を設定する

Firebase functionでサーバーを立てる場合、Lernaと一緒に使うには少々トリックが必要のようです。
その理由は

* デプロイされると`npm install`を自動で行うので、Lernaがサブプロジェクトを`dependencies`に含めた結果シンボリックリンクが動かず、結局動作しない
* シンプルに全体をwebpackでバンドリングしてもトップにexportsが必要なのでうまく動作しない(?)

なんにせよサブプロジェクトの部分を含むべくサーバー側もwebpackに通してあげれば動作するはずです。

**webpack.config.js**

```javascript
const path = require("path");
const webpack = require("webpack");

module.exports = {
  target: 'node',
  entry: {
    api: './src/api/api.ts'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name]/[name].js',
    libraryTarget: 'commonjs2'
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'babel-loader'
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: ['.js', '.json', '.ts']
  }
}
```


webpackの設定はこんな感じです。`target`が`node`になっていること、`output.libraryTarget`が`commonjs2`になっていることを確認してください。

.babelrcは以下のようにします。Typescriptの設定しかしていません。

```json
{
  "presets": [
    "@babel/preset-typescript"
  ]
}

```

`src/api/api.ts`を作成して以下のような内容を含めます。

```typescript
import * as express from 'express'
import { CounterResult } from '@firebase-monorepo/api-schema'
const app = express()
const result = new CounterResult()
app.get("/",(req,res)=>{
  res.send(""+result.count);
});
export default app;
```

`src/index.js`も作成し以下のような内容を含めます。

```javascript
const functions = require('firebase-functions')
const APIRouter = require("./api/api").default;
module.exports = {
    api:functions.https.onRequest(APIRouter)
}
```

さらにデプロイ時に含めるための`src/package.json`も作成してこのようにdependencyがほとんどないpackage.jsonを作ります。

```json
{
    "name": "release",
    "scripts": {
    },
    "dependencies": {
        "firebase-admin": "^6.2.0",
        "firebase-functions": "^2.1.0"
    },
    "devDependencies": {
    }
}
```

こうした上で以下のようなビルドコマンドを書いてdistのサブフォルダにバンドリングされた結果が入っていくようにします。

```json
    "build": "webpack && npm run build:copy_entry",
    "build:copy_entry": "cp ./src/*.* ./dist",
```

サーバーサイドをバンドリングするというのはなかなか奇妙な構成ですが、サブプロジェクトの動作もしっかりしますし、型も十分に動作します。サーバー側のエラーもソースマップを用いることができる構成です。

# デプロイ環境作成編

Firebaseにデプロイできるようにします。今回は`stagin環境`、`production環境`の2つを用意することにしましょう。
ここではFirebaseのコンソールから、ステージング用のプロジェクトと本番環境用のプロジェクトの二つを以下のように用意しました。

* qiita-firebase-monorepo(本番環境)
* qiita-firebase-monorepo-stg(ステージング環境)

同じ名前は取れないと思うので、実際に試す方は自分で好きな名前を入れて環境を作成しましょう。

ルートフォルダにて`npm install firebase-tools --save-dev`を実行してfirebase toolsを入れましょう。

ルートのpackage.jsonに以下のように追加します。

```json
    "deploy": "lerna run build && npx firebase deploy"
```

## .firebasercを設定する

ルートフォルダ直下に以下のような`.firebaserc`を入れます。こうすると、`firebase use staging`とするとデプロイ先がstagingになり、`firebase use production`とするとデプロイ先が本番環境になります。

```json
{
  "projects": {
    "production": "qiita-firebase-monorepo",
    "staging": "qiita-firebase-monorepo-stg"
  }
}
```

## firebase.jsonを作成する

ルートフォルダ直下に以下のような`firebase.json`を含めます。`

```json
{
    "hosting": {
        "public": "./packages/client/dist",
        "ignore":[
            "**/.*"
        ]
    },
    "functions": {
        "source": "./packages/server/dist"
    }
}
```

それぞれ`dist`の中身がpublishされることに注意が必要です。さらにfunctionsの方は`dist`の中のpackage.jsonのdependencyはデプロイ時にリモート側でインストールされます。必要があればここに外部参照を記述して、webpackのビルドの時に一部のrequireだけ残すのも良いでしょう。
(本当はサブプロジェクトだけバンドリングできたら一番いいのだけれど)


# テスト環境作成編

## jestを導入する

jestを用いてテスト環境を構築してみましょう。
ルートフォルダで以下のようなコマンドを実行します。

```bash
$ lerna add jest --dev
$ lerna add ts-jest-babel-7 --dev
$ lerna add @types/jest --dev
```

次にサブプロジェクトのそれぞれのpackage.jsonに以下のような項目を追加します。

```json
  "jest": {
    "transform": {
      "^.+\\.tsx?$": "ts-jest-babel-7"
    },
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  }
```

さらにpackage.jsonのscriptsのtestコマンドを`jest`にします。

```json
    "test": "jest"
```

試しにserverのサブプロジェクトにテストを追加します。`src/__test__`を作成して、`api.test.ts`を追加します。

```typescript

import server from "./../api"
import { CounterResult } from "@firebase-monorepo/api-schema";
describe('hello', () => {
  it('hello("jest") to be "Hello Jest!!"', () => {
    expect('Hello Jest!!').toBe('Hello Jest!!')
  })
  it('Requireing sub project should work', () => {
    expect(CounterResult).not.toBe(null)
  })
})

```

`lerna run test`をルートフォルダで実行すればテストが全サブプロジェクトで動作することが確認できます。

## コードカバレッジを測定する

各プロジェクトに`test:coverage`を以下のように追加します。jestはこれだけでカバレッジ出してくれます。神

```json
    "test:coverage": "jest --coverage",
```

# CI/CD環境作成編

CircleCIを用いていい感じにCI/CD環境を作成します。手動デプロイはどのバージョンがデプロイされているかわからなくなったりしますし、ビルドするの忘れてデプロイしてしまったりします。

`.circleci/config.yml`を作成します。


## 依存関係のインストール+ビルド+テスト

circleciのキャッシュの設定が少々複雑です。というのも、それぞれのサブパッケージ単位でのインストールされたモジュールのキャッシュ化、ビルド済みファイルの同一リビジョンのjob間でのキャッシュの利用のためです。
とりあえずは以下のようなconfig.ymlにします。

```yml
# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2.1
executors:
  node:
    docker: 
      - image: circleci/node:8
commands:
  restore_built:
    description: "Restore the build result from cache"
    steps:
      - restore_cache:
          key: built-api-schema-{{ .Revision }}
      - restore_cache:
          key: built-client-{{ .Revision }}
      - restore_cache:
          key: built-server-{{ .Revision }}
  restore_dependency:
    description: "Restore the dependencies result from cache"
    steps:
      - restore_cache:
          keys:
            - dependencies-root-{{ checksum "package.json" }}
            - dependencies-root-
      - restore_cache:
          keys:
            - dependencies-api-schema-{{ checksum "packages/api-schema/package.json" }}
            - dependencies-api-schema-
      - restore_cache:
          keys:
            - dependencies-client-{{ checksum "packages/client/package.json" }}
            - dependencies-client-
      - restore_cache:
          keys:
            - dependencies-server-{{ checksum "packages/server/package.json" }}
            - dependencies-server-    
jobs:
  install_dependency:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ルートの依存関係のインストール
          command: npm install
      - run:
          name: サブプロジェクトの依存関係のインストール
          command: npx lerna bootstrap
      - run:
          name: package.jsonの避難
          command: cp ./package.json ./package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/api-schema/package.json ./packages/api-schema/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/client/package.json ./packages/client/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/server/package.json ./packages/server/package.copy.json
      - save_cache:
          paths:
            - ./node_modules/
          key: dependencies-root-{{ checksum "package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/api-schema/node_modules/
          key: dependencies-api-schema-{{ checksum "packages/api-schema/package.copy.json }}
      - save_cache:
          paths:
            - ./packages/client/node_modules/
          key: dependencies-client-{{ checksum "packages/client/package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/server/node_modules/
          key: dependencies-server-{{ checksum "packages/server/package.copy.json" }}
  build:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ビルド
          command: npx lerna run build
      - save_cache:
          paths:
            - ./packages/api-schema/lib/
          key: built-api-schema-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/client/dist/
          key: built-client-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/server/dist/
          key: built-server-{{ .Revision }}
  test:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: テスト
          command: npx lerna run test:coverage
workflows:
  main:
    jobs:
      - install_dependency
      - build:
          requires:
            - install_dependency
      - test:
          requires:
            - build
```

キャッシュの復元部分は今後書いていく他のjobでも共通なので、commandsに書いておくと便利です。
`npm install`が`package.json`を変えてしまう可能性があるので、依存関係をインストールする前にインストール後にハッシュが計算ができるように避難をします。

## デプロイの設定をする

ビルドができているのでステージング環境に自動でデプロイされるようにします。

`jobs`に以下のコマンドを追加します。

```yml
  deploy_staging:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Staging)
          command: npx firebase use staging
      - run:
          name: デプロイ
          command: npx firebase deploy
  pre_deploy_production:
    executor: node
    steps:
      - run:
          name: send notification to slack
          command: >
              curl -X POST -H 'Content-type: application/json' 
              --data "{\"text\": \"本番環境へのデプロイにはManual Approvalが必要です。https://XXXXK を確認の上、本番環境へデプロイしていい場合は次のアドレスから承認してください。<https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}|here>.\"}"
              $WEBHOOK_URL
  deploy_production:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Production)
          command: npx firebase use production
      - run:
          name: デプロイ
          command: npx firebase deploy
```

workflow全体は以下のようになります。

```yml
workflows:
  main:
    jobs:
      - install_dependency
      - build:
          requires:
            - install_dependency
      - test:
          requires:
            - build
      - deploy_staging:
          requires:
            - test
          filters:
            branches:
              only: 
                - staging
                - master
      - pre_deploy_production:
          requires:
            - deploy_staging
          filters:
            branches:
              only: 
                - master
      - approve_deploy_production:
          type: approval
          requires:
            - pre_deploy_production
          filters:
            branches:
              only: 
                - master
      - deploy_production:
          requires:
            - approve_deploy_production
          filters:
            branches:
              only: master
```

この設定ファイルで行われるのは以下の通りです。

* stagingブランチもしくはmasterブランチの場合、stagingの方のfirebaseプロジェクトとしてpublishされます。
* masterだった場合、さらにslackなどに通知するjobを通った後で、productionにデプロイする手前でmanual approveを要求します。
* CircleCI上でデプロイにOKをしたら勝手にデプロイされます。


## デプロイ時にタグをつける

デプロイされているのがどのバージョンかわかるようにするためにタグをつけることにしましょう。

```yml
      - run:
          name: 'プロダクションリリースへのタグ付け'
          command: 'git tag production/rev-${CIRCLE_BUILD_NUM}'
      - run:
          name: 'タグのpush'
          command: 'git push origin --tags'
```

のようなコードをデプロイに含めるだけでCircleCIからgit上でタグづけされます。非常に便利。

## 有用な外部サービスと連携する

### Codecovとの連携

https://codecov.io/

カバレッジが集計されているのでこれを可視化しましょう。ルートで`npm i codecov --save-dev`をします。
testジョブを以下のように書き換えるとcodecovにカバレッジが表示されるようになります。

```yml
  test:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: テスト
          command: npx lerna run test:coverage
      - run:
          name: Codecovに送る
          command: npx codecov
```

CircleCIの管理画面でCODECOV_TOKEN環境変数を登録する必要があります。

### Cloudflareのキャッシュをパージする

Firebasehostingのドメインを独自ドメインにしている場合などにCloudflareを用いていると、デプロイした場合もCloudflareのキャッシュのせいでクライアントでリロードしても更新されない場合があります。このような場合にはデプロイの最後にキャッシュのパージをするAPIを叩く処理を入れれば大丈夫です。

```yml
      - run:
          name: 'CloudflareのキャッシュをPurge'
          command: 'curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONEID/purge_cache"\
 -H "X-Auth-Email: $CLOUDFLARE_AUTHMAIL"  -H "X-Auth-Key: $CLOUDFLARE_AUTHKEY" -H "Content-Type: \
application/json" --data "{\"purge_everything\":true}"'
```

CLOUDFLARE_ZONEID、CLOUDFLARE_AUTHMAIL CLOUDFLARE_AUTHKEYの環境変数を設定する必要があります。


## CIの設定ファイルの振り返り

```yml
# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2.1
executors:
  node:
    docker: 
      - image: circleci/node:8
commands:
  restore_built:
    description: "Restore the build result from cache"
    steps:
      - restore_cache:
          key: built-api-schema-{{ .Revision }}
      - restore_cache:
          key: built-client-{{ .Revision }}
      - restore_cache:
          key: built-server-{{ .Revision }}
  restore_dependency:
    description: "Restore the dependencies result from cache"
    steps:
      - restore_cache:
          keys:
            - dependencies-root-{{ checksum "package.json" }}
            - dependencies-root-
      - restore_cache:
          keys:
            - dependencies-api-schema-{{ checksum "packages/api-schema/package.json" }}
            - dependencies-api-schema-
      - restore_cache:
          keys:
            - dependencies-client-{{ checksum "packages/client/package.json" }}
            - dependencies-client-
      - restore_cache:
          keys:
            - dependencies-server-{{ checksum "packages/server/package.json" }}
            - dependencies-server-    
jobs:
  install_dependency:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ルートの依存関係のインストール
          command: npm install
      - run:
          name: サブプロジェクトの依存関係のインストール
          command: npx lerna bootstrap
      - run:
          name: package.jsonの避難
          command: cp ./package.json ./package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/api-schema/package.json ./packages/api-schema/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/client/package.json ./packages/client/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/server/package.json ./packages/server/package.copy.json
      - save_cache:
          paths:
            - ./node_modules/
          key: dependencies-root-{{ checksum "package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/api-schema/node_modules/
          key: dependencies-api-schema-{{ checksum "packages/api-schema/package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/client/node_modules/
          key: dependencies-client-{{ checksum "packages/client/package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/server/node_modules/
          key: dependencies-server-{{ checksum "packages/server/package.copy.json" }}
  build:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ビルド
          command: npx lerna run build
      - save_cache:
          paths:
            - ./packages/api-schema/lib/
          key: built-api-schema-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/client/dist/
          key: built-client-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/server/dist/
          key: built-server-{{ .Revision }}
  test:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: テスト
          command: npx lerna run test:coverage
      - run:
          name: Codecovに送る
          command: npx codecov

  deploy_staging:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Staging)
          command: npx firebase use staging
      - run:
          name: デプロイ
          command: npx firebase deploy --token "$FIREBASE_TOKEN"
      - run:
          name: 'プロダクションリリースへのタグ付け'
          command: 'git tag staging/rev-${CIRCLE_BUILD_NUM}'
      - run:
          name: 'タグのpush'
          command: 'git push origin --tags'
  pre_deploy_production:
    executor: node
    steps:
      - run:
          name: send notification to slack
          command: >
              curl -X POST -H 'Content-type: application/json' 
              --data "{\"text\": \"本番環境へのデプロイにはManual Approvalが必要です。http://XXXX を確認の上、本番環境へデプロイしていい場合は次のアドレスから承認してください。<https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}|here>.\"}"
              $WEBHOOK_URL
  deploy_production:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Production)
          command: npx firebase use production
      - run:
          name: デプロイ
          command: npx firebase deploy --token "$FIREBASE_TOKEN"
      - run:
          name: 'プロダクションリリースへのタグ付け'
          command: 'git tag production/rev-${CIRCLE_BUILD_NUM}'
      - run:
          name: 'タグのpush'
          command: 'git push origin --tags'
workflows:
  main:
    jobs:
      - install_dependency
      - build:
          requires:
            - install_dependency
      - test:
          requires:
            - build
      - deploy_staging:
          requires:
            - test
          filters:
            branches:
              only: 
                - staging
                - master
      - pre_deploy_production:
          requires:
            - deploy_staging
          filters:
            branches:
              only: 
                - master
      - approve_deploy_production:
          type: approval
          requires:
            - pre_deploy_production
          filters:
            branches:
              only: 
                - master
      - deploy_production:
          requires:
            - approve_deploy_production
          filters:
            branches:
              only: master

```

全体としてこのような設定ファイルになっていれば大丈夫です。これは最低限度のCIの設定で、理想的にはE2Eのテスト環境を導入したかったり、Sentryなどのエラー管理ツールとの連携もする必要があるでしょう。