GulpとCircleCIでChrome拡張機能のリリースプロセスを自動化してみた

これは、[Unipos](https://unipos.me)を一斉送信するChrome拡張機能「[Send Unipos together](https://chrome.google.com/webstore/detail/send-unipos-together/pgpnkghddnfoopjapnlklllpjknnibkn)」を作ったときに得た知見を記事にしたものです。

# リリースプロセスを自動化したい動機

Chrome拡張機能を作ることは、JavaScriptでプログラミングができる人であれば、比較的簡単です。最低限必要なのはテキストエディターとGoogle Chromeだけです。以下の情報を参照すれば作り方やAPIについてを知ることができます。

- [Develop Extensions - Google Chrome](https://developer.chrome.com/extensions/devguide)
- [ブラウザー拡張機能 - Mozilla | MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions)

また、作ったChrome拡張機能を[Chromeウェブストア](https://chrome.google.com/webstore/category/extensions?hl=ja)で公開して配布することも、それほど難しくありません。Chrome拡張機能を公開するときに使う[Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard)は日本語にも対応しています。

- [Distributing Products Built for Chrome - Google Chrome](https://developer.chrome.com/webstore)

ただ、作ったChrome拡張機能を何度もアップデートしてリリースしたい場合、リリースまでに付随する煩雑な手作業が苦痛になってきます。

- タスクの実行
    - テスト
    - パッケージ化
    - パッケージのアップロード
    - パッケージの公開
- 依存モジュールの管理
- バージョン管理

これらの作業を自動化できると、リリース作業の手間とミスが減り、Chrome拡張機能の機能改善により集中できるようになります。

# 自動化されたリリースプロセス

自動化されたリリースプロセスでは、作ったChrome拡張機能を新しくパッチ・バージョンを上げてアイテムを公開したい場合、次のコマンドを実行するだけです。

```sh:sh
npm version patch
```

そうすると、次のことが自動で実行されます。

1. `manifest.json` / `package.json` / `package-lock.json` の`version`を更新してコミット
2. Gitでリリースバージョンのタグを打つ
3. GitHubにプッシュ
4. CircleCIでワークフローにもとづいて各ジョブを実行
    1. テスト / Lint 
    2. 更新パッケージの作成とアップロード
    3. Approve待ち
    4. アイテムの公開

とっても簡単です。

# GulpもCircleCIもChrome拡張機能の開発もやったことがあるひとへ

[send-unipos-together](https://github.com/naokikimura/send-unipos-together/)のコードを見たほうが理解が早いかもしれません。

- [`.circleci/config.yml`](https://github.com/naokikimura/send-unipos-together/blob/1e4f2e435dfc8154124f65e72a456e822f5f3c23/.circleci/config.yml)
- [`gulpfile.js`](https://github.com/naokikimura/send-unipos-together/blob/1e4f2e435dfc8154124f65e72a456e822f5f3c23/gulpfile.js)
- [`package.json`](https://github.com/naokikimura/send-unipos-together/blob/1e4f2e435dfc8154124f65e72a456e822f5f3c23/package.json)
- [`test/popup.spec.ts`](https://github.com/naokikimura/send-unipos-together/blob/1e4f2e435dfc8154124f65e72a456e822f5f3c23/test/popup.spec.ts)

CircleCIは、特に凝った使い方はしてません。普段は、テストとLintのジョブだけ動かし、リリースタグが付いたときには、更新パッケージを作り、それをChrome Web Storeにアップロードして、Approveしたらアイテムを公開するワークフローです。

Gulpは、コマンド実行するだけで済むタスクは`child_process.spawn`を使い、極力プラグインを使わないようにしています。Chrome拡張機能のパッケージ・アップロード・公開については、やや複雑なタスクなので、次のプラグインを使っています。

- [gulp-package-dependencies](https://www.npmjs.com/package/gulp-package-dependencies)
- [gulp-unify-versions](https://www.npmjs.com/package/gulp-unify-versions)
- [gulp-zip](https://www.npmjs.com/package/gulp-zip)
- [gulp-chrome-web-store](https://www.npmjs.com/package/gulp-chrome-web-store)

Chrome拡張機能で使うパッケージはnpmで管理します。`assemble`タスクで`package.json`の`dependencies`に挙げられているパッケージを`node_modules`からコピーしてChrome拡張機能のパッケージに含めます。

Chrome Web Store Developer Dashboardは使いません。「更新パッケージをアップロード」は`deploy:upload`タスクで実行します。「アイテムの公開」は`publish`タスクで行います。

`manifest.json`の`version`は`package.json`の`version`と同期させています。`npm version`コマンドの実行をトリガーに`manifest.json`の`version`を`package.json`の`version`の値に変更する`version:sync`タスク実行されるように`package.json`の`version`スクリプトに仕込んでいます。

テストの一環として、Chrome拡張機能がChromeで動くかを[Puppeteer](https://pptr.dev)を使ってテストしています。

# 採用した技術

- [Gulp](https://gulpjs.com)
- [npm](https://www.npmjs.com)
- [GitHub](https://github.com)
- [CircleCI](https://circleci.com)
- [Puppeteer](https://pptr.dev)

## タスクランナー

作業を自動化するためには、タスクランナーが必要になります。タスクランナー(ないしは、それに似たもの)としては、いくつかの選択肢があります。

- シェルスクリプト
- npm / yarn などのパッケージ管理システムのスクリプト実行機構
- webpack / Browserify / Rollup / Parcel などのバンドラー
- Grunt
- Gulp

その中からボクは、[Gulp](https://gulpjs.com)を選びました。

選ぶポイントとしたのは、次のようなことです。

- 相互運用性 (Windows / Mac OS など色々なOS上で動作させたい)
- シンプルなメカニズム (理解しやすく余計なことをしない)
- 拡張性 (思い通りに機能を追加できる)

シェルスクリプトは、相互運用性が実現するのが難しいので却下。npmなどのパッケージ管理システムのスクリプト実行機構は、シンプルすぎて凝ったことしたいとき大変なので却下。バンドラーをタスクランナーとして使えないかとも思ったが、バンドラーにデプロイのタスクを負わせるのは筋違いので却下。

残ったのは、純粋なタスクランナーであるGruntとGulpでした。どちらでも良かったのですが、まずGulpを試したところ、[Gulpのコンセプト](https://gulpjs.com/docs/en/api/concepts)がしっくりきたので、Gruntを試すことなく、Gulpを選ぶことにしました。

Gulpについて批判的な意見もありますが、闇雲にプラグインを使わずにタスクをシンプルに保ってば、破綻しないと思っています。

## 依存モジュールの管理

ちょっとしたChrome拡張機能を作るときにも、一からすべて自分で開発するのではなく、すでにあるモジュール(ライブラリーやパッケージといったもの)を利用したいものです。そのほうが効率的です。

ただ、Chrome拡張機能の開発では、決められたモジュール管理システムはありません。何を使っても自由です。もちろん、手作業でモジュールをダウンロードしてパッケージに含めても構いません。とはいえ、セキュリティなどを考えるとモジュールのバージョン管理やアップデートは必要になります。やはり、なんらかのモジュール管理システムを導入したほうが便利です。

タスクランナーとしてGulpを選んだので、その時点でChrome拡張のモジュール管理も[npm](https://www.npmjs.com)で行うことにしました。

npmでモジュールを管理することで、GitHubのセキュリティアラートや[Snyk](https://snyk.io)の支援を受けることができます。

## バージョン管理

Chrome拡張機能のソースコードも当然バージョン管理したいです。

これも色々選択肢がありますが、ボクは日常的に使って慣れている[GitHub](https://github.com)を選びました。

## CI/CD環境

リリースプロセスを自動化するためには、バージョン管理システムと連動してタスクを自動実行してくれるCI/CD環境が欲しくなります。

当初は、[Travis CI](https://travis-ci.org)を使っていましたが、[CircleCI](https://circleci.com)に切り替えました。

切り替えた決定的な理由は、Travis CIではChrome拡張機能のテストが実現できなかったからです。Chrome拡張機能をテストするためには、Chromeをヘッドレスモードではなくフルモードで起動する必要があるのですが、Travis CIではChromeをフルモードで起動できませんでした。これについては、Travis CIの使い方がまずかったのか現時点でTravis CIでは無理なのかはわかっていません。

# 一連の作業をタスク化してGulpで実行できるようにする

前置きが長くなりましたが、ここからは具体的な作り方を解説します。

## 前提

- GitHubでリポジトリーは作成済み
- GitHubからリポジトリーをクローン済み
- Node.jsはインストール済み
- 作っているChrome拡張機能はChrome Web Storeに登録済み

## 想定

Chrome拡張機能を構成するファイルは、プロジェクト・ルートフォルダーにフラットに置かれていることを想定して話を進めていきます。

```
.
├── .git
├── .gitignore
├── LICENSE
├── README.md
├── _locales
│   └── ja
│       └── messages.json
├── background.js
├── icon128.png
├── manifest.json
├── options.css
├── options.html
├── options.js
├── popup.css
├── popup.html
└── popup.js
```

## `package.json`を作る

`npm init`を実行して`package.json`を生成します。

```sh:sh
npm init -y
```

そうすると、こんな感じでできあがります。

```json:package.json
{
  "name": "send-unipos-together",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/naokikimura/send-unipos-together.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/naokikimura/send-unipos-together/issues"
  },
  "homepage": "https://github.com/naokikimura/send-unipos-together#readme"
}
```

できあがった`package.json`を編集します。説明を簡潔にするため不要な項目は、バッサリと切っています。

必要な項目は、次の3つです。

- `name`: パッケージの名前。リポジトリー名と同じにしておきます
- `version`: パッケージのバージョン。`manifest.json`の`version`と同じ値にします
- `private`: npmに誤って公開しないように`true`にしておきます

```diff:package.json
 {
   "name": "send-unipos-together",
-  "version": "1.0.0",
-  "description": "",
-  "main": "index.js",
-  "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
-  },
-  "repository": {
-    "type": "git",
-    "url": "git+https://github.com/naokikimura/send-unipos-together.git"
-  },
-  "keywords": [],
-  "author": "",
-  "license": "ISC",
-  "bugs": {
-    "url": "https://github.com/naokikimura/send-unipos-together/issues"
-  },
-  "homepage": "https://github.com/naokikimura/send-unipos-together#readme"
+  "version": "0.0.1",
+  "private": true
 }
```

まだなんのパッケージを追加していませんが、一旦 `npm install`コマンドを実行して `package-lock.json`を作っておきます。

加えて、`.gitignore`に`node_modules/`を追記します。

```diff:.gitignore
+ node_modules/
```

## Gulpをインストールする

npmでGulpをインストールします。

Chrome拡張機能に含めないパッケージは`-D`オプションを付けてインストールします。

```sh:sh
npm i -D gulp
```

Gulpを実行したいときは`npx`コマンドを使います。

```sh:sh
npx gulp --version
```

## `gulpfile.js`を作る

まずは`gulpfile.js`というファイル名で空ファイルを作ります。

`gulpfile.js`を作ったら確認のためにGulpを実行してみます。`No gulpfile found`と表示されなければ問題ないです。

```sh:sh
npx gulp --tasks
```

## Gulpでシンプルなタスクを作れるようにする

### Gulpタスクの基本

Gulpは、`gulpfile.js`にやりたいことを「タスク」という単位で関数をJavaScriptで記述します。(参考: [Creating Tasks · gulp.js](https://gulpjs.com/docs/en/getting-started/creating-tasks))

タスクは非同期で実行されます。そのためタスク関数は、いくつかの決められた形式(ストリーム / `Promise` / 子プロセス など)の戻り値を返すかコールバック関数を呼び出す必要があります。(参考:[Creating Tasks · gulp.js](https://gulpjs.com/docs/en/getting-started/async-completion))

Gulp自体は基礎的な機能のみを提供して、多くのことはプラグインを取り入れて実現します。Gulpが提供する基礎的な機能として重要なのが次の3つです。

- [`src()`](https://gulpjs.com/docs/en/api/src): glob形式で指定したファイル一覧から各ファイルを`Vinyl`オブジェクトでラップし、それの一覧を[`stream.Readable`](https://nodejs.org/api/stream.html#stream_class_stream_readable)で返す
- [`dest()`](https://gulpjs.com/docs/en/api/dest): 一連の`Vinyl`オブジェクトの`stream.Readable`を読み込んで指定されたパスに書き出す
- [`Vinyl`](https://gulpjs.com/docs/en/api/vinyl): 仮想的なファイルを表現したオブジェクト

基本的にタスクは、ストリームを[`.pipe()`](https://nodejs.org/api/stream.html#stream_readable_pipe_destination_options)でつないで一連の処理を書き表してます。

```js:gulpfile.js
const { src, dest } = require('gulp');
const zip = require('gulp-zip');

exports.pack = () => {
    return src(['./manifest.json', './background.js'])
        .pipe(zip('package.zip'))
        .pipe(dest('./'));
}
```

これらがGulpを使うコツです。様々なGulpプラグインもこれらに則って作られています。

### コマンド実行で済むタスクは`child_process.spawn`を使う

例えば[Mocha](https://mochajs.org)を使ってテストを実行するタスクの例としてよく見かけるのが次のようなものです。

```js:gulpfile.js
const gulp = require('gulp');
const mocha = require('gulp-mocha');

exports.test = () => {
  return gulp.src('test/**/*.js', { read: false })
    .pipe(mocha({ reporter: 'nyan' }));
}
```

十分にシンプルですが、いくつか気に入らないことがあります。

- 単に`npx mocha`と実行したいだけなのに、プラグインのインストールが必要
    - プラグインを検索する必要がある
    - プラグインが正しく機能して安全かを見極める必要がある
    - プラグインの依存によっては`node_modules`が肥大化する懸念がある
- Mochaの設定は[`.mocharc.js`などの設定ファイル](https://mochajs.org/#configuring-mocha-nodejs)に集約したい
- Mochaはデフォルトで`test`フォルダーからテストファイルを探してくれるのに、わざわざテストファイルのパスを指定する必要がある

そこで、次のような[`child_process.spawn`](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options)を使った小さなインラインプラグインを用意します。

```js:gulpfile.js
function spawn(command, args = [], options) {
  const child = require('child_process')
    .spawn(command, args.filter(e => e === 0 || e), options);
  if (child.stdout) child.stdout.pipe(process.stdout);
  if (child.stderr) child.stderr.pipe(process.stderr);
  return child;
}
```

これを使ったMochaでテストするタスクはこう記述します。

```js:gulpfile.js
exports.test = () => {
  return spawn('mocha');
}
```

これだけです。簡単です。これで`npx gulp test`も`npx mocha`も同じ実行結果になります。

## テストタスクを作る

まずは、テストランナーをインストールします。ここではMochaを使いますが、テストランナーは[Jest](https://jestjs.io/ja/)など好きなものに置き換えてもいいです。

```sh:sh
npm i -D mocha chai
```

テストランナーの設定など準備ができたら、先で解説したように`gulpfile.js`にテストタスクを記述します。

まず、コマンド実行のインラインプラグインを書きます。

```diff:gulpfile.js
+function spawn(command, args = [], options) {
+  const child = require('child_process')
+    .spawn(command, args.filter(e => e === 0 || e), options);
+  if (child.stdout) child.stdout.pipe(process.stdout);
+  if (child.stderr) child.stderr.pipe(process.stderr);
+  return child;
+}
+
```

そうしたら、テストタスクを書きます。


```diff:gulpfile.js
   return child;
 }
 
  
+exports.test = () => {
+  return spawn('mocha');
+}
+
```

この時点で`gulpfile.js`はこうなります。

```js:gulpfile.js
function spawn(command, args = [], options) {
  const child = require('child_process')
    .spawn(command, args.filter(e => e === 0 || e), options);
  if (child.stdout) child.stdout.pipe(process.stdout);
  if (child.stderr) child.stderr.pipe(process.stderr);
  return child;
}

exports.test = () => {
  return spawn('mocha');
}
```

テストしたいときは `npx gulp test` と実行します。

## PuppeteerでChrome拡張機能のE2Eテストする

E2Eテストとしましたが、最低限のテストとしてChrome拡張機能がChromeにインストールされて起動するかをテストするのが目的です。

最低限のテストを怠って動かないChrome拡張機能を公開しようとすると、Chrome Web Storeに公開を拒否される恐れがあります。しかも、審査結果が返されるまで数日かかることもあります。その間、更新パッケージのアップロードはできなくなります。審査の取り下げはできません。

## Puppeteerをインストールする

npmでPuppeteerをインストールしましょう。

```sh:sh
npm i -D puppeteer
```

### PuppeteerでChrome拡張機能を動かす

Puppeteerのドキュメント「[Working with Chrome Extensions](https://pptr.dev/#?product=Puppeteer&version=v2.0.0&show=api-working-with-chrome-extensions)」に例が記載されているので、それに倣えば簡単です。

### ポップアップを開く

ポップアップを開きたい場合は、こんな感じになります。

```js
const puppeteer = require('puppeteer');


/*
 * Chromeを起動する
 */
const extensionPath = process.cwd();
const browser = await puppeteer.launch({
  args: [
    `--load-extension=${extensionPath}`, // 拡張機能をロード
    `--disable-extensions-except=${extensionPath}`, // 不要な拡張機能を無効にする
  ],
  headless: false, // ヘッドレスモードではなく、フルモードで
});

/*
 * ポップアップを開く
 */
const extensionPage = await browser.newPage();
// 拡張機能のIDは都度変わるので動的に取得する
const extensionID = ((targets) => {
  const extensionTarget = targets.find(target => target.type() === 'background_page');
  const [, , id] = extensionTarget.url().split('/');
  return id;
  })(await browser.targets());
const extensionPopupHtml = 'popup.html'; // ポップアップのパス
await extensionPage.goto(`chrome-extension://${extensionID}/${extensionPopupHtml}`);
});
```

### テストランナーでテストを書く

PuppeteerでChrome拡張機能を動かす方法がわかったので、あとはテストランナーでテストできるようにします。

具体的なコードは[`test/popup.spec.ts`](https://github.com/naokikimura/send-unipos-together/blob/1e4f2e435dfc8154124f65e72a456e822f5f3c23/test/popup.spec.ts)を参照してください。

いくつかポイントがあります。

- Chromeの起動は時間がかかるため、テストのタイムアウトを少し長めに設定しましょう
- テストケースの事前処理でChromeを起動して、テストケースの事後処理でChromeを閉じましょう
- TypeScriptで記述していますが、そこは重要ではありません。

## Lintするタスクを作る

必須ではないですが、テストとは別にLintもしたいと多くの人は思うでしょう。

ここでは[ESLint](https://eslint.org)を例にLintするタスクを作ります。

まずは、Linterをインストールします。

```sh:sh
npm i -D eslint
```

次に、Linterの設定ファイルを作ります。

```sh:sh
npx eslint -- --init
```

設定できたら、Lintしてみましょう。

```sh:sh
npx eslit .
```

あとは、タスクにするだけです。

```js:gulpfile.js
exports.lint = () => {
  const options = ['.'];
  return spawn('eslint', options);
}
```

JavaScriptだけではなく、HTMLやCSSのLinterも使いたいなら、同じようにタスクを作成して、`lint`タスクにまとめ、`gulp.parallel()`で各Linterを並列で実行するようにしましょう。

```diff:gulpfile.js
-  exports.lint = () => {
+  exports[lint:eslit] = () => {
    const options = ['.'];
    return spawn('eslint', options);
  }
+
+ exports['lint:stylelint'] = () {
+   const options = ['./**/*.css'];
+   return spawn('stylelint', options);
+ }
+ 
+ exports.lint = gulp.parallel(exports['lint:eslint'], exports['lint:stylelint']);
```

## 依存モジュールを管理する

Chrome拡張機能で使いたいモジュール(パッケージ)は、npmで管理します。

ここでは、リセットCSSの[ress](https://ress-css.surge.sh/)を使いたい場合を例にします。

### 依存パッケージを追加する

`npm install`コマンドに`-S`オプションをつけてパッケージをインストールします。

```sh:sh
npm i -S ress
```

すると、`node_modules`フォルダーにパッケージが追加され、`package.json`の`dependencies`にパッケージが追記されます。

```diff:package.json
     "gulp": "^4.0.2",
     "mocha": "^7.0.1",
     "puppeteer": "^2.0.0"
+  },
+  "dependencies": {
+    "ress": "^2.0.4"
   }
 }
```

### 依存モジュールを参照する

ここらへんについては、バンドラーを使ってスマートに解決するという方法もあり得ると思っていますが、リリースまでに必要な構成要素をさらに加えて考慮を増やさないためにあえて取り入れませんでした。

使いたいパッケージをインストールしたので、`node_modules`フォルダーにパッケージを構成するファイルが展開されています。パッケージを使う場合は、そのパスを指定すれば可能です。

安直にするなら、こうなります。

```html:popup.html
<!DOCTYPE html>

<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <title>Send Unipos together</title>
  <link rel="stylesheet" type="text/css" href="/node_modules/ress/dist/ress.min.css">
</head>

<body>
</body>

</html>

```

ただ、このままだとChrome拡張機能のパッケージの中に`node_modules`フォルダーを含める必要があります。が、これは問題です。困ります。`node_modules`には開発でしか使わないGulpやPuppeteerも含まれているので、Chrome拡張機能のパッケージのサイズが膨れ上がってしまいます。

この問題を避けるため、依存モジュールは`node_modules`フォルダーから別のフォルダーにコピーして、そのフォルダーに含まれるファイルを参照するようにします。Chrome拡張機能をパッケージするときもその別フォルダーを含めます。こういった、ファイルをまとめておいておくフォルダーはよく`dist`という名前がつけられます。

```diff:popup.html
 <head>
   <meta charset="UTF-8" />
   <title>Send Unipos together</title>
-  <link rel="stylesheet" type="text/css" href="/node_modules/ress/dist/ress.min.css">
+  <link rel="stylesheet" type="text/css" href="/dist/ress/dist/ress.min.css">
 </head>
 
 <body>
```

`dist/`は通常Gitの管理対象にしないので、`.gitignore`に除外エントリーを追記しておきます。

```diff:.gitignore
  node_modules/
+ dist/
```

### 依存モジュールを`dist/`にコピーするタスクを作る

依存モジュールを`node_modules/`から`dist/`にコピーするのは、それなりに面倒です。

- `package.json`の`dependencies`と同期している必要がある
- 依存モジュールが依存するモジュールもコピーする必要がある

その面倒な作業を解決するために作ったのが[gulp-package-dependencies](https://www.npmjs.com/package/gulp-package-dependencies)です。

まず、gulp-package-dependenciesをインストールします。

```sh:sh
npm i -D gulp-package-dependencies
```

そして、次のように`gulpfile.js`にタスクを追記します。

```js:gulpfile.js

exports.assemble = () => {
  const gulp = require('gulp');
  const dependencies = require('gulp-package-dependencies');
  return dependencies()
    .pipe(gulp.dest('./dist'));
}
```

このタスクを使いたいときは、次のようにします。

```sh:sh
npx gulp assemble
```

## ビルドタスクを定義する

Chrome拡張機能をモジュールに依存させたので、Chrome拡張機能を動かす前には`assemble`タスクを実行して、モジュールの依存を解決する必要がでてきました。

このタイミングで、Chrome拡張機能を動かす前に必要なタスクをまとめ、`build`タスクとして定義しておきます。

まずは、`build`タスクで`assemble`タスクが実行するようにします。


```js:gulp
exports.build = gulp.parallel(exports.assemble);
```

### Transpileとかするタスク

TypeScriptなどのTranspilerを使っている場合は、Transpileするタスクを作りましょう。

```js:gulpfile.js
exports['transpile:tsc'] = () => {
  return spawn('tsc');
}

exports['transpile:sass'] = () => {
  return spawn('sass', ['src/:dist/']);
}

exports.transpile = gulp.parallel(exports['transpile:sass'], exports['transpile:tsc']);
```

タスクを作ったら、`build`タスクに加えます。

```diff:gulpfile.js
- exports.build = gulp.parallel(exports.assemble);
+ exports.build = gulp.parallel(exports.assemble, exports.transpile);
```


Minifyもしたいなら同じようにタスクを作ります。

ただ、Chrome拡張機能においては、Minifyは必要ではないと思います。Chrome拡張機能はパッケージで配布されてChromeにインストールして利用されるので、パッケージに必要なリソースを含めればリソースを読み込むまでのオーバーヘッドはかなり小さいですからです。まあ、パッケージのサイズも小さいに越したことはないですが。

TranspilerもMinifyも使っていない場合は、タスクを作る必要はありません。

### テストタスクの前にビルドタスクを実行する

テストでも依存モジュールの解決が必要なので、テストタスクの前にビルドタスクを実行するように修正します。

```diff:gulpfile.js
- exports.test = () => {
+ exports['test:mocha'] = () => {
    return spawn('mocha');
  }
+ 
+ exports.test = gulp.series(exports.build, exports['test:mocha']);
```

## パッケージバージョンをBumpする

Chrome拡張機能の更新パッケージを作る前に`manifest.json`を`version`をbumpする(上げる)必要があります。手作業で行うなら、手順はこうなります。

1. `manifest.json`を`version`をbump (`0.0.1`を`0.0.2`にするとか)
2. bumpしたファイルをコミット (`git commit -am '0.0.2'`とか)
3. バージョンのタグを打つ (`git tag v0.0.2`とか)

npmにはこれと同じようなことをする`npm version`コマンドがあります。`package.json`と`package-lock.json`のパッチバージョンをbumpしたいときは、こうコマンドを実行します。

```sh:sh
npm version patch
```

`manifest.json`もこれと同じようにスマートにbumpしたいです。

[`npm version`コマンドのドキュメント](https://docs.npmjs.com/cli-commands/version.html)をよく読むと、コマンド実行の間にスクリプトを差し込めることがわかります。

- `preversion`スクリプト: `npm version`コマンドの前処理
- `version`スクリプト: `package.json`と`package-lock.json`をbumpした直後でコミットする前の処理
- `postversion`スクリプト: `npm version`コマンドの後処理

`version`スクリプトをうまく使うことで、`npm version`コマンドで`manifest.json`もbumpできそうです。

1. `package.json`と`package-lock.json`がbumpされる
2. `version`スクリプトで`manifest.json`の`version`をbumpされた`package.json`の`version`と同じにする
3. コミット&タグされる

とりあえず、`package.json`に`version`スクリプトを差し込んでみましょう。

```diff:package.json
   "name": "send-unipos-together",
   "version": "0.0.0",
   "private": true,
+  "scripts": {
+    "version": "gulp version:unify && git add manifest.json"
+  },
   "devDependencies": {
     "chai": "^4.2.0",
     "gulp": "^4.0.2",
```

### `package.json`と`manifest.json`の`version`を同期するタスクを作る

このタスク、やりたいことはシンプルですが、実装しようとするとやや複雑です。

1. `package.json`の`version`を読み込む
2. `manifest.json`の`version`を書き換える

[`jq`](https://stedolan.github.io/jq/)コマンドと`sed`コマンドを駆使すればできそうですが、コマンドに依存することになるので、[gulp-unify-versions](https://www.npmjs.com/package/gulp-unify-versions)プラグインを作りました。

このプラグインを使ってタスクを作ります。

```js:gulpfile.js
exports['version:unify'] = () => {
  const unify = require('gulp-unify-versions');
  return gulp.src('./manifest.json')
    .pipe(unify('./package.json'))
    .pipe(gulp.dest('./'));
}
```

### Bumpしてみる

実際に`npm version`コマンドで`manifest.json`がbumpされるか試してみましょう。

```sh:sh
npm version patch
```

`git show`などで意図通りにbumpされたか確認しましょう。

## パッケージするタスクを作る

Chrome拡張機能のパッケージを作るタスクは、次のタスクで構成します。

1. ビルドするタスク
2. 必要なファイルをzipファイルにまとめるタスク

```js:gulpfile.js
exports.pack = gulp.series(exports.build, exports['pack:zip']);
```
### 必要なファイルをzipファイルにまとめるタスク

当初のファイル構成では、単にプロジェクトフォルダーをzipファイルにすれば良かったのですが、zipファイルに含めたくないフォルダーやファイルが増えてきたため、この作業も手作業では面倒になってきます。

このタスクでは、[gulp-zip](https://www.npmjs.com/package/gulp-zip)を使います。

- zipファイルに何を含めるかは`gulp.src()`で指定します
    - 含めたくないファイルは`ignore`オプションで指定できます
- zipファイルの名前は`gulp-zip`の引数で指定します
    - ファイル名は`package.json`の`name`と`version`から構成します
- zipファイルの出力先フォルダーは`gulp.dest()`で指定します
    - 出力先フォルダーはパッケージに含めないようにしましょう


```js:gulpfile.js
exports['pack:zip'] = () => {
  const { name, version } = require('./package.json');
  const zip = require('gulp-zip');
  const ignore = [
    '.*',
    'artifacts{,/**/*}',
    'gulpfile.js',
    'node_modules{,/**/*}',
    'package{,-lock}.json',
    'test{,/**/*}',
  ];
  return gulp.src('./**/*', { ignore })
    .pipe(zip(`${name}-${version}.zip`))
    .pipe(gulp.dest('./artifacts'));
}

```

合わせて、出力先フォルダーを`.gitignore`でも除外しましょう。

```diff:.gitignore
  node_modules/
  dist/
+ artifacts/
```

## Gulpで更新パッケージを作ってみる

ここまでの`gulpfile.js`をまとめると、詳細のようになります。

<details><div>

```js:gulpfile
const gulp = require('gulp');

function spawn(command, args = [], options) {
  const child = require('child_process')
    .spawn(command, args.filter(e => e === 0 || e), options);
  if (child.stdout) child.stdout.pipe(process.stdout);
  if (child.stderr) child.stderr.pipe(process.stderr);
  return child;
}

exports['version:unify'] = () => {
  const unify = require('gulp-unify-versions');
  return gulp.src('./manifest.json')
    .pipe(unify('./package.json'))
    .pipe(gulp.dest('./'));
}

exports['test:mocha'] = () => {
  return spawn('mocha');
}

exports.lint = () => {
  const options = ['.'];
  return spawn('eslint', options);
}

exports.assemble = () => {
  const dependencies = require('gulp-package-dependencies');
  return dependencies()
    .pipe(gulp.dest('./dist'));
}

exports['pack:zip'] = () => {
  const { name, version } = require('./package.json');
  const zip = require('gulp-zip');
  const ignore = [
    '.*',
    'artifacts{,/**/*}',
    'gulpfile.js',
    'node_modules{,/**/*}',
    'package{,-lock}.json',
    'test{,/**/*}',
  ];
  return gulp.src('./**/*', { ignore })
    .pipe(zip(`${name}-${version}.zip`))
    .pipe(gulp.dest('./artifacts'));
}

exports.build = gulp.parallel(exports.assemble);
exports.test = gulp.series(exports.build, exports['test:mocha']);
exports.pack = gulp.series(exports.build, exports['pack:zip']);
```

</div></details>

Chrome拡張機能のパッケージを作りたいときは、こうコマンドを実行します。

```sh:sh
npx gulp pack
```

そうすると、`artifacts/`の中にzipファイルができあがります。

## 更新パッケージをアップロードしてアイテムを公開するタスクを作る

更新パッケージを作れるようになったので、あとはそのパッケージを自動でアップロードしてChrome Web Storeにアイテムを公開したいところです。

Chrome Web StoreはAPIを提供しています。そのAPIを使えばアイテムをアップロード・公開するスクリプトを作れ、Gulpのタスクを作ることも可能です。

- [Chrome Web Store API Reference - Google Chrome](https://developer.chrome.com/webstore/api_index)
- [Using the Chrome Web Store Publish API - Google Chrome](https://developer.chrome.com/webstore/using_webstore_api)

Chrome Web Store APIを使うためには、次の3つの情報が必要です。

- 認証情報 (クライアントIDとクライアントシークレット)
- アクセストークン(と、リフレッシュトークン)
- アイテムID

認証情報とアクセストークンは、[Using the Chrome Web Store Publish APIのBefore you beginの段落](https://developer.chrome.com/webstore/using_webstore_api#beforeyoubegin)に従って、取得してください。これが面倒くさいのですが、一度限りの作業なので頑張りましょう。

アイテムIDは、[Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard)から確認できます。

Chrome Web Store APIは、REST APIなのでHTTPクライアントライブラリーを使えばプログラミングは簡単です。とは言え、そのスクリプトを長々と`gulpfile.js`に書いておきたくはありません。なので、プラグインにしました。それが[gulp-chrome-web-store](https://www.npmjs.com/package/gulp-chrome-web-store)です。

### gulp-chrome-web-storeを使う

gulp-chrome-web-storeの使い方は、[gulp-chrome-web-storeのREDME](https://github.com/naokikimura/gulp-chrome-web-store#readme)に書いてあります。

まずは、gulp-chrome-web-storeをインストールします。

```sh:sh
npm i -D gulp-chrome-web-store
```

gulp-chrome-web-storeを使ってアイテムのアップロードと公開をするためには、まず設定が必要です。その設定に必要な認証情報とアクセストークンは機密情報なので、安全に取り扱わなければなりません。なので、`gulpfile.js`に直書きせず、環境変数を介して情報を得るようにします。

```js:gulpfile.js
const item = require('gulp-chrome-web-store')(
  process.env.CHROME_WEB_STORE_API_CREDENTIAL,
  process.env.CHROME_WEB_STORE_API_ACCESS_TOKEN_RESPONSE,
).item('pgpnkghddnfoopjapnlklllpjknnibkn');
```

### 更新パッケージをアップロードするタスクを作る

このタスクは単純です。作った更新パッケージをgulp-chrome-web-storeの`item`の`upload()`に渡してあげるだけです。

```js:gulpfile.js
exports['deploy:upload'] = () => {
  const { name, version } = require('./package.json');
  return gulp.src(`./artifacts/${name}-${version}.zip`)
    .pipe(item.upload());
}

exports.deploy = gulp.series(exports.pack, exports['deploy:upload']);
```

### Chrome Web Storeにアイテムを公開するタスクを作る

このタスクも非常に単純です。gulp-chrome-web-storeの`item`の`publish()`を呼ぶだけです。

```js:gulpfile.js
exports.publish = () => {
  return item.publish();
}
```

## Gulpでアイテムを公開してみる

ここまでの`gulpfile.js`をまとめると、詳細のようになります。

<details><div>

```js:gulpfile
const gulp = require('gulp');

function spawn(command, args = [], options) {
  const child = require('child_process')
    .spawn(command, args.filter(e => e === 0 || e), options);
  if (child.stdout) child.stdout.pipe(process.stdout);
  if (child.stderr) child.stderr.pipe(process.stderr);
  return child;
}

exports['version:unify'] = () => {
  const unify = require('gulp-unify-versions');
  return gulp.src('./manifest.json')
    .pipe(unify('./package.json'))
    .pipe(gulp.dest('./'));
}

exports['test:mocha'] = () => {
  return spawn('mocha');
}

exports.lint = () => {
  const options = ['.'];
  return spawn('eslint', options);
}

exports.assemble = () => {
  const dependencies = require('gulp-package-dependencies');
  return dependencies()
    .pipe(gulp.dest('./dist'));
}

exports['pack:zip'] = () => {
  const { name, version } = require('./package.json');
  const zip = require('gulp-zip');
  const ignore = [
    '.*',
    'artifacts{,/**/*}',
    'gulpfile.js',
    'node_modules{,/**/*}',
    'package{,-lock}.json',
    'test{,/**/*}',
  ];
  return gulp.src('./**/*', { ignore })
    .pipe(zip(`${name}-${version}.zip`))
    .pipe(gulp.dest('./artifacts'));
}

const item = require('gulp-chrome-web-store')(
  process.env.CHROME_WEB_STORE_API_CREDENTIAL,
  process.env.CHROME_WEB_STORE_API_ACCESS_TOKEN_RESPONSE,
).item('pgpnkghddnfoopjapnlklllpjknnibkn');

exports['deploy:upload'] = () => {
  const { name, version } = require('./package.json');
  return gulp.src(`./artifacts/${name}-${version}.zip`)
    .pipe(item.upload());
}

exports.publish = () => {
  return item.publish();
}

exports.build = gulp.parallel(exports.assemble);
exports.test = gulp.series(exports.build, exports['test:mocha']);
exports.pack = gulp.series(exports.build, exports['pack:zip']);
exports.deploy = gulp.series(exports.pack, exports['deploy:upload']);
```

</div></details>

更新パッケージをアップロードしてアイテムを公開したいときは、こうコマンドを実行します。

```sh:sh
npx gulp --series deploy publish
```

## ここまでの変更をコミットする

`gulpfile.js`の編集が一段落したので、このあたりでここまでに追加・変更したファイルをコミットしておきましょう。

# デプロイメントパイプラインを組む

Gulpを使ってアイテムを公開するまでの一連の作業を自動化できるようになりました。あとはその一連の作業をCI環境で実行できるようにデプロイメントパイプラインを組みます。GitHubリポジトリーのブランチにプッシュされるたびに必要なタスクが自動で実行されるようにします。

## `.circleci/config.yml`を作る

次の公式ドキュメントを参考にして`.circleci/config.yml`を作ります。

- [CircleCI を設定する - CircleCI](https://circleci.com/docs/ja/2.0/configuration-reference/)
- [Node.js - JavaScript チュートリアル - CircleCI](https://circleci.com/docs/ja/2.0/language-javascript/)

### `version`フィールドの定義

`commands`や`executors`を使うので`2.1`にします。

```yml:.circleci/config.yml
version: 2.1
```

### Executorsの定義

> Executors は、ジョブステップの実行環境を定義するものです。executor を 1 つ定義するだけで複数のジョブで再利用できます。

```yml:.circleci/config.yml
executors:
  default:
    docker:
    - image: circleci/node:lts-browsers

    working_directory: ~/repo
```

`-browsers`で終わるタグが付けられたイメージを使うのがポイントです。これらのイメージにはGoogle Chromeが含まれています。Google Chromeがインストールされていないと、テストのタスクでPuppeteerがGoogle Chromeの起動に失敗して、次のようなエラーになります。

```
Failed to launch chrome!
/home/circleci/repo/node_modules/puppeteer/.local-chromium/linux-706915/chrome-linux/chrome: error while loading shared libraries: libXtst.so.6: cannot open shared object file: No such file or directory
```

### コマンドの定義

> commands は、ジョブ内で実行するステップシーケンスをマップとして定義します。これを活用することで、複数のジョブ間で [コマンド定義の再利用](https://circleci.com/docs/ja/2.0/reusing-config/)が可能になります。

`restore_cache`を使ってキャッシュを復元するステップは複数のジョブで使うのでコマンドとして定義して再利用できるようにしておきます。

```yml:.circleci/config.yml
commands:
  restore_node_modules:
    steps:
    - restore_cache:
        keys:
        - v1-dependencies-{{ checksum "package.json" }}
        - v1-dependencies-
```

### ジョブの定義

> 実行処理は 1 つ以上の名前の付いたジョブで構成され、それらのジョブの指定は `jobs` マップで行います。

次の5つのジョブを定義します。

- `setup`: `npm install`して`node_modules`をキャッシュに保存する
- `test`: `npx gulp test`でテストを実行して、その結果を保存する
- `lint`: `npx gulp lint`でLintを実行して、その結果を保存する
- `deploy`: `npx gulp deploy`で更新パッケージを作り、それをChrome Web Storeにアップロードする。更新パッケージは成果物として保存する
- `publish`: `npx gulp publish`でアイテムをChrome Web Storeに公開する

```yml:.circleci/config.yml
jobs:
  setup:
    executor:
      name: default

    steps:
    - checkout
    - restore_node_modules
    - run: npm install

    - save_cache:
        paths:
        - node_modules
        key: v1-dependencies-{{ checksum "package.json" }}

  test:
    executor:
      name: default

    steps:
    - checkout
    - restore_node_modules

    - run: npx gulp test

    - store_test_results:
        path: ./reports

  lint:
    executor:
      name: default

    steps:
    - checkout
    - restore_node_modules

    - run: npx gulp lint

    - store_test_results:
        path: ./reports

  deploy:
    executor:
      name: default

    steps:
      - checkout
      - restore_node_modules

      - deploy:
          command: npx gulp deploy

      - store_artifacts:
          path: ./artifacts

  publish:
    executor:
      name: default

    steps:
      - checkout
      - restore_node_modules

      - deploy:
          command: npx gulp publish
```

### Workflowの定義

> あらゆるジョブの自動化に用います。Workflow 1 つ 1 つはそれぞれ名前となるキーと、値となるマップからなります。

このようなWorkflowを定義します。

<img width="1127" alt="スクリーンショット 2020-01-31 14.49.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80531/15eef202-31b5-3609-1290-f5c3d5eabcf1.png">

1. まず`setup`して、その後に並行して`test`と`lint`を行う
2. Gitタグが付いていたら、`test`の後に`deploy`する
3. `deploy`の後に`hold`でapprovalを待つ
4. `approval`されたら`publish`する

アイテムを公開すると、Chrome Web Storeでの審査を待つことになります。審査結果が出るまでは、更新パッケージのアップロードはできなくなります。オペレーションを間違えて誤ったアイテムを公開しないために、`publish`の前にApproval(手動の承認操作)を設けます。Approvalについては、[承認後に処理を続行する Workflow の例](https://circleci.com/docs/ja/2.0/workflows/#%E6%89%BF%E8%AA%8D%E5%BE%8C%E3%81%AB%E5%87%A6%E7%90%86%E3%82%92%E7%B6%9A%E8%A1%8C%E3%81%99%E3%82%8B-workflow-%E3%81%AE%E4%BE%8B)を参照してください。

```yml:.circleci/config.yml
workflows:
  test-deploy-publish:
    jobs:
    - setup:
        filters:
          tags:
            only: /.*/
          branches:
            only: /.*/
    - test:
        filters:
          tags:
            only: /.*/
          branches:
            only: /.*/
        requires:
        - setup
    - lint:
        filters:
          tags:
            only: /.*/
          branches:
            only: /.*/
        requires:
        - setup
    - deploy:
        filters:
          tags:
            only: /^v[0-9]+\.[0-9]+\.[0-9]+$/
          branches:
            ignore: /.*/
        requires:
        - test
    - hold:
        type: approval
        filters:
          tags:
            only: /^v[0-9]+\.[0-9]+\.[0-9]+$/
          branches:
            ignore: /.*/
        requires:
        - deploy
    - publish:
        filters:
          tags:
            only: /^v[0-9]+\.[0-9]+\.[0-9]+$/
          branches:
            ignore: /.*/
        requires:
        - hold
```

### `.circleci/config.yml`を検証する

まずは、[CircleCI のローカル CLI の使用 - CircleCI](https://circleci.com/docs/ja/2.0/local-cli/)を参考にして`circleci`コマンドをインストールしてください。

`circleci`がインストールできたら、次のようにコマンドを実行します。

```sh:sh
circleci config check
```

`Config file at .circleci/config.yml is valid.`と表示されれば、構文上の問題がないということになります。

### CI環境に合わせてGulpタスクを修正する

`test`ジョブでは、`store_test_results`ステップでテスト結果を保存するようにしています。ただし、テスト結果の形式をJUnit XMLかCucumber JSONにしてファイルに出力する必要があります。

ジョブの実行環境では`CI`環境変数に`true`が設定されるので、それを判断材料にしてコマンド引数などを変更します。

テストランナーがMochaなら、`gulpfile.js`をこんな感じに修正します。

```diff:gulpfile.js
 exports.test = () => {
-  return spawn('mocha');
+  const options = process.env.CI
+    ? ['-R', 'xunit', '-O', 'output=./reports/mocha/test-results.xml']
+    : [];
+  return spawn('mocha', options);
 }

  exports.lint = () => {
-   const options = ['.'];
+   const options = ['.']
+      .concat(process.env.CI ? ['-f', 'junit', '-o', './reports/eslint/test-results.xml'] : []);
    return spawn('eslint', options);
  }
 ```

### `.circleci/config.yml`をリモートリポジトリーにプッシュする

`.circleci/config.yml`が出来上がったら、コミットしてリモートリポジトリーにプッシュしましょう。

## CircleCIにプロジェクトを追加する

[Setting up Your Build on CircleCI - Getting Started Introduction - CircleCI](https://circleci.com/docs/2.0/getting-started/#setting-up-your-build-on-circleci)を参考にプロジェクトを追加してください。

## 環境変数を設定する

[プロジェクト内で環境変数を設定する - 環境変数の使い方 - CircleCI](https://circleci.com/docs/ja/2.0/env-vars/#%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E5%86%85%E3%81%A7%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B)を参考にして、次の環境変数を設定します。

- `CHROME_WEB_STORE_API_CREDENTIAL`: Chrome Web Store APIの認証情報
- `CHROME_WEB_STORE_API_ACCESS_TOKEN_RESPONSE`: Chrome Web Store APIのアクセストークン

## バージョンをBumpしたら同時にリポジトリーへのプッシュまでする

`npm version`コマンドでバージョンをBumpした後は、それをリモートリポジトリーにプッシュする必要があります。どうせならこれも自動化しましょう。

`package.json`の`postversion`スクリプトに`git push --follow-tags`を仕込みます。

```diff:package.json
   "version": "0.0.0",
   "scripts": {
-    "version": "gulp version:unify && git add manifest.json"
+    "version": "gulp version:unify && git add manifest.json",
+    "postversion": "git push --follow-tags"
   },
   "devDependencies": {
```

## 新しいバージョンのアイテムを公開してみる

はい。準備が整ったので、パッチバージョンを上げたアイテムをChrome Web Storeに公開してみましょう。

```sh:sh
npm version patch
```

そうすると、GitHubにプッシュされ、それに連動してCircleCIでWorkflowが走ります。テストが正常終了して、それに続いて、更新パッケージのアップロードが成功したら、Workflowはapprovalされるまで一時停止します。Workflowをapprovalすれば、晴れてアイテムが公開されます。

# まとめ

GulpとCircleCIを組み合わせて、Chrome拡張機能のリリースプロセスを自動化してみました。記事が長くなってしまいましたが、仕組みは汎用的なので、いろいろなChrome拡張機能の開発に活かせるものになっています。