4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-31

これは、Uniposを一斉送信するChrome拡張機能「Send Unipos together」を作ったときに得た知見を記事にしたものです。

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

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

また、作ったChrome拡張機能をChromeウェブストアで公開して配布することも、それほど難しくありません。Chrome拡張機能を公開するときに使うChrome Web Store Developer Dashboardは日本語にも対応しています。

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

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

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

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

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

sh
npm version patch

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

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

とっても簡単です。

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

send-unipos-togetherのコードを見たほうが理解が早いかもしれません。

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

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

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

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

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

テストの一環として、Chrome拡張機能がChromeで動くかをPuppeteerを使ってテストしています。

採用した技術

タスクランナー

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

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

その中からボクは、Gulpを選びました。

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

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

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

残ったのは、純粋なタスクランナーであるGruntとGulpでした。どちらでも良かったのですが、まずGulpを試したところ、Gulpのコンセプトがしっくりきたので、Gruntを試すことなく、Gulpを選ぶことにしました。

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

依存モジュールの管理

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

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

タスクランナーとしてGulpを選んだので、その時点でChrome拡張のモジュール管理もnpmで行うことにしました。

npmでモジュールを管理することで、GitHubのセキュリティアラートやSnykの支援を受けることができます。

バージョン管理

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

これも色々選択肢がありますが、ボクは日常的に使って慣れているGitHubを選びました。

CI/CD環境

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

当初は、Travis CIを使っていましたが、CircleCIに切り替えました。

切り替えた決定的な理由は、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
npm init -y

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

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.jsonversionと同じ値にします
  • private: npmに誤って公開しないようにtrueにしておきます
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を作っておきます。

加えて、.gitignorenode_modules/を追記します。

.gitignore
+ node_modules/

Gulpをインストールする

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

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

sh
npm i -D gulp

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

sh
npx gulp --version

gulpfile.jsを作る

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

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

sh
npx gulp --tasks

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

Gulpタスクの基本

Gulpは、gulpfile.jsにやりたいことを「タスク」という単位で関数をJavaScriptで記述します。(参考: Creating Tasks · gulp.js)

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

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

  • src(): glob形式で指定したファイル一覧から各ファイルをVinylオブジェクトでラップし、それの一覧をstream.Readableで返す
  • dest(): 一連のVinylオブジェクトのstream.Readableを読み込んで指定されたパスに書き出す
  • Vinyl: 仮想的なファイルを表現したオブジェクト

基本的にタスクは、ストリームを.pipe()でつないで一連の処理を書き表してます。

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を使ってテストを実行するタスクの例としてよく見かけるのが次のようなものです。

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などの設定ファイルに集約したい
  • Mochaはデフォルトでtestフォルダーからテストファイルを探してくれるのに、わざわざテストファイルのパスを指定する必要がある

そこで、次のようなchild_process.spawnを使った小さなインラインプラグインを用意します。

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でテストするタスクはこう記述します。

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

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

テストタスクを作る

まずは、テストランナーをインストールします。ここではMochaを使いますが、テストランナーはJestなど好きなものに置き換えてもいいです。

sh
npm i -D mocha chai

テストランナーの設定など準備ができたら、先で解説したようにgulpfile.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;
+}
+

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

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

この時点でgulpfile.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
npm i -D puppeteer

PuppeteerでChrome拡張機能を動かす

Puppeteerのドキュメント「Working with Chrome Extensions」に例が記載されているので、それに倣えば簡単です。

ポップアップを開く

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

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を参照してください。

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

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

Lintするタスクを作る

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

ここではESLintを例にLintするタスクを作ります。

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

sh
npm i -D eslint

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

sh
npx eslint -- --init

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

sh
npx eslit .

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

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

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

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を使いたい場合を例にします。

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

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

sh
npm i -S ress

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

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

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

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

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

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

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という名前がつけられます。

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に除外エントリーを追記しておきます。

.gitignore
  node_modules/
+ dist/

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

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

  • package.jsondependenciesと同期している必要がある
  • 依存モジュールが依存するモジュールもコピーする必要がある

その面倒な作業を解決するために作ったのがgulp-package-dependenciesです。

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

sh
npm i -D gulp-package-dependencies

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

gulpfile.js

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

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

sh
npx gulp assemble

ビルドタスクを定義する

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

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

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

gulp
exports.build = gulp.parallel(exports.assemble);

Transpileとかするタスク

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

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タスクに加えます。

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

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

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

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

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

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

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

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

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

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

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

sh
npm version patch

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

npm versionコマンドのドキュメントをよく読むと、コマンド実行の間にスクリプトを差し込めることがわかります。

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

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

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

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

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.jsonmanifest.jsonversionを同期するタスクを作る

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

  1. package.jsonversionを読み込む
  2. manifest.jsonversionを書き換える

jqコマンドとsedコマンドを駆使すればできそうですが、コマンドに依存することになるので、gulp-unify-versionsプラグインを作りました。

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

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
npm version patch

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

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

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

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

必要なファイルをzipファイルにまとめるタスク

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

このタスクでは、gulp-zipを使います。

  • zipファイルに何を含めるかはgulp.src()で指定します
    • 含めたくないファイルはignoreオプションで指定できます
  • zipファイルの名前はgulp-zipの引数で指定します
    • ファイル名はpackage.jsonnameversionから構成します
  • zipファイルの出力先フォルダーはgulp.dest()で指定します
    • 出力先フォルダーはパッケージに含めないようにしましょう
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でも除外しましょう。

.gitignore
  node_modules/
  dist/
+ artifacts/

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

ここまでのgulpfile.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']);

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

sh
npx gulp pack

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

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

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

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

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

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

認証情報とアクセストークンは、Using the Chrome Web Store Publish APIのBefore you beginの段落に従って、取得してください。これが面倒くさいのですが、一度限りの作業なので頑張りましょう。

アイテムIDは、Chrome Web Store Developer Dashboardから確認できます。

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

gulp-chrome-web-storeを使う

gulp-chrome-web-storeの使い方は、gulp-chrome-web-storeのREDMEに書いてあります。

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

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

gulp-chrome-web-storeを使ってアイテムのアップロードと公開をするためには、まず設定が必要です。その設定に必要な認証情報とアクセストークンは機密情報なので、安全に取り扱わなければなりません。なので、gulpfile.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のitemupload()に渡してあげるだけです。

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のitempublish()を呼ぶだけです。

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

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

ここまでのgulpfile.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']);

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

sh
npx gulp --series deploy publish

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

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

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

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

.circleci/config.ymlを作る

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

versionフィールドの定義

commandsexecutorsを使うので2.1にします。

.circleci/config.yml
version: 2.1

Executorsの定義

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

.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 は、ジョブ内で実行するステップシーケンスをマップとして定義します。これを活用することで、複数のジョブ間で コマンド定義の再利用が可能になります。

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

.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に公開する
.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を定義します。

スクリーンショット 2020-01-31 14.49.36.png
  1. まずsetupして、その後に並行してtestlintを行う
  2. Gitタグが付いていたら、testの後にdeployする
  3. deployの後にholdでapprovalを待つ
  4. approvalされたらpublishする

アイテムを公開すると、Chrome Web Storeでの審査を待つことになります。審査結果が出るまでは、更新パッケージのアップロードはできなくなります。オペレーションを間違えて誤ったアイテムを公開しないために、publishの前にApproval(手動の承認操作)を設けます。Approvalについては、承認後に処理を続行する Workflow の例を参照してください。

.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を参考にしてcircleciコマンドをインストールしてください。

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

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をこんな感じに修正します。

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を参考にプロジェクトを追加してください。

環境変数を設定する

プロジェクト内で環境変数を設定する - 環境変数の使い方 - CircleCIを参考にして、次の環境変数を設定します。

  • 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.jsonpostversionスクリプトにgit push --follow-tagsを仕込みます。

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
npm version patch

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

まとめ

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

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?