これは、Uniposを一斉送信するChrome拡張機能「Send Unipos together」を作ったときに得た知見を記事にしたものです。
リリースプロセスを自動化したい動機
Chrome拡張機能を作ることは、JavaScriptでプログラミングができる人であれば、比較的簡単です。最低限必要なのはテキストエディターとGoogle Chromeだけです。以下の情報を参照すれば作り方やAPIについてを知ることができます。
また、作ったChrome拡張機能をChromeウェブストアで公開して配布することも、それほど難しくありません。Chrome拡張機能を公開するときに使うChrome Web Store Developer Dashboardは日本語にも対応しています。
ただ、作ったChrome拡張機能を何度もアップデートしてリリースしたい場合、リリースまでに付随する煩雑な手作業が苦痛になってきます。
- タスクの実行
- テスト
- パッケージ化
- パッケージのアップロード
- パッケージの公開
- 依存モジュールの管理
- バージョン管理
これらの作業を自動化できると、リリース作業の手間とミスが減り、Chrome拡張機能の機能改善により集中できるようになります。
自動化されたリリースプロセス
自動化されたリリースプロセスでは、作ったChrome拡張機能を新しくパッチ・バージョンを上げてアイテムを公開したい場合、次のコマンドを実行するだけです。
npm version patch
そうすると、次のことが自動で実行されます。
-
manifest.json
/package.json
/package-lock.json
のversion
を更新してコミット - Gitでリリースバージョンのタグを打つ
- GitHubにプッシュ
- CircleCIでワークフローにもとづいて各ジョブを実行
- テスト / Lint
- 更新パッケージの作成とアップロード
- Approve待ち
- アイテムの公開
とっても簡単です。
GulpもCircleCIもChrome拡張機能の開発もやったことがあるひとへ
send-unipos-togetherのコードを見たほうが理解が早いかもしれません。
CircleCIは、特に凝った使い方はしてません。普段は、テストとLintのジョブだけ動かし、リリースタグが付いたときには、更新パッケージを作り、それをChrome Web Storeにアップロードして、Approveしたらアイテムを公開するワークフローです。
Gulpは、コマンド実行するだけで済むタスクはchild_process.spawn
を使い、極力プラグインを使わないようにしています。Chrome拡張機能のパッケージ・アップロード・公開については、やや複雑なタスクなので、次のプラグインを使っています。
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を使ってテストしています。
採用した技術
タスクランナー
作業を自動化するためには、タスクランナーが必要になります。タスクランナー(ないしは、それに似たもの)としては、いくつかの選択肢があります。
- シェルスクリプト
- 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
を生成します。
npm init -y
そうすると、こんな感じでできあがります。
{
"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
にしておきます
{
"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/
を追記します。
+ node_modules/
Gulpをインストールする
npmでGulpをインストールします。
Chrome拡張機能に含めないパッケージは-D
オプションを付けてインストールします。
npm i -D gulp
Gulpを実行したいときはnpx
コマンドを使います。
npx gulp --version
gulpfile.js
を作る
まずはgulpfile.js
というファイル名で空ファイルを作ります。
gulpfile.js
を作ったら確認のためにGulpを実行してみます。No gulpfile found
と表示されなければ問題ないです。
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()
でつないで一連の処理を書き表してます。
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を使ってテストを実行するタスクの例としてよく見かけるのが次のようなものです。
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
を使った小さなインラインプラグインを用意します。
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でテストするタスクはこう記述します。
exports.test = () => {
return spawn('mocha');
}
これだけです。簡単です。これでnpx gulp test
もnpx mocha
も同じ実行結果になります。
テストタスクを作る
まずは、テストランナーをインストールします。ここではMochaを使いますが、テストランナーはJestなど好きなものに置き換えてもいいです。
npm i -D mocha chai
テストランナーの設定など準備ができたら、先で解説したように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;
+}
+
そうしたら、テストタスクを書きます。
return child;
}
+exports.test = () => {
+ return spawn('mocha');
+}
+
この時点で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をインストールしましょう。
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をインストールします。
npm i -D eslint
次に、Linterの設定ファイルを作ります。
npx eslint -- --init
設定できたら、Lintしてみましょう。
npx eslit .
あとは、タスクにするだけです。
exports.lint = () => {
const options = ['.'];
return spawn('eslint', options);
}
JavaScriptだけではなく、HTMLやCSSのLinterも使いたいなら、同じようにタスクを作成して、lint
タスクにまとめ、gulp.parallel()
で各Linterを並列で実行するようにしましょう。
- 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
オプションをつけてパッケージをインストールします。
npm i -S ress
すると、node_modules
フォルダーにパッケージが追加され、package.json
のdependencies
にパッケージが追記されます。
"gulp": "^4.0.2",
"mocha": "^7.0.1",
"puppeteer": "^2.0.0"
+ },
+ "dependencies": {
+ "ress": "^2.0.4"
}
}
依存モジュールを参照する
ここらへんについては、バンドラーを使ってスマートに解決するという方法もあり得ると思っていますが、リリースまでに必要な構成要素をさらに加えて考慮を増やさないためにあえて取り入れませんでした。
使いたいパッケージをインストールしたので、node_modules
フォルダーにパッケージを構成するファイルが展開されています。パッケージを使う場合は、そのパスを指定すれば可能です。
安直にするなら、こうなります。
<!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
という名前がつけられます。
<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
に除外エントリーを追記しておきます。
node_modules/
+ dist/
依存モジュールをdist/
にコピーするタスクを作る
依存モジュールをnode_modules/
からdist/
にコピーするのは、それなりに面倒です。
-
package.json
のdependencies
と同期している必要がある - 依存モジュールが依存するモジュールもコピーする必要がある
その面倒な作業を解決するために作ったのがgulp-package-dependenciesです。
まず、gulp-package-dependenciesをインストールします。
npm i -D gulp-package-dependencies
そして、次のようにgulpfile.js
にタスクを追記します。
exports.assemble = () => {
const gulp = require('gulp');
const dependencies = require('gulp-package-dependencies');
return dependencies()
.pipe(gulp.dest('./dist'));
}
このタスクを使いたいときは、次のようにします。
npx gulp assemble
ビルドタスクを定義する
Chrome拡張機能をモジュールに依存させたので、Chrome拡張機能を動かす前にはassemble
タスクを実行して、モジュールの依存を解決する必要がでてきました。
このタイミングで、Chrome拡張機能を動かす前に必要なタスクをまとめ、build
タスクとして定義しておきます。
まずは、build
タスクでassemble
タスクが実行するようにします。
exports.build = gulp.parallel(exports.assemble);
Transpileとかするタスク
TypeScriptなどのTranspilerを使っている場合は、Transpileするタスクを作りましょう。
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
タスクに加えます。
- exports.build = gulp.parallel(exports.assemble);
+ exports.build = gulp.parallel(exports.assemble, exports.transpile);
Minifyもしたいなら同じようにタスクを作ります。
ただ、Chrome拡張機能においては、Minifyは必要ではないと思います。Chrome拡張機能はパッケージで配布されてChromeにインストールして利用されるので、パッケージに必要なリソースを含めればリソースを読み込むまでのオーバーヘッドはかなり小さいですからです。まあ、パッケージのサイズも小さいに越したことはないですが。
TranspilerもMinifyも使っていない場合は、タスクを作る必要はありません。
テストタスクの前にビルドタスクを実行する
テストでも依存モジュールの解決が必要なので、テストタスクの前にビルドタスクを実行するように修正します。
- exports.test = () => {
+ exports['test:mocha'] = () => {
return spawn('mocha');
}
+
+ exports.test = gulp.series(exports.build, exports['test:mocha']);
パッケージバージョンをBumpする
Chrome拡張機能の更新パッケージを作る前にmanifest.json
をversion
をbumpする(上げる)必要があります。手作業で行うなら、手順はこうなります。
-
manifest.json
をversion
をbump (0.0.1
を0.0.2
にするとか) - bumpしたファイルをコミット (
git commit -am '0.0.2'
とか) - バージョンのタグを打つ (
git tag v0.0.2
とか)
npmにはこれと同じようなことをするnpm version
コマンドがあります。package.json
とpackage-lock.json
のパッチバージョンをbumpしたいときは、こうコマンドを実行します。
npm version patch
manifest.json
もこれと同じようにスマートにbumpしたいです。
npm version
コマンドのドキュメントをよく読むと、コマンド実行の間にスクリプトを差し込めることがわかります。
-
preversion
スクリプト:npm version
コマンドの前処理 -
version
スクリプト:package.json
とpackage-lock.json
をbumpした直後でコミットする前の処理 -
postversion
スクリプト:npm version
コマンドの後処理
version
スクリプトをうまく使うことで、npm version
コマンドでmanifest.json
もbumpできそうです。
-
package.json
とpackage-lock.json
がbumpされる -
version
スクリプトでmanifest.json
のversion
をbumpされたpackage.json
のversion
と同じにする - コミット&タグされる
とりあえず、package.json
にversion
スクリプトを差し込んでみましょう。
"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
を同期するタスクを作る
このタスク、やりたいことはシンプルですが、実装しようとするとやや複雑です。
-
package.json
のversion
を読み込む -
manifest.json
のversion
を書き換える
jq
コマンドとsed
コマンドを駆使すればできそうですが、コマンドに依存することになるので、gulp-unify-versionsプラグインを作りました。
このプラグインを使ってタスクを作ります。
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されるか試してみましょう。
npm version patch
git show
などで意図通りにbumpされたか確認しましょう。
パッケージするタスクを作る
Chrome拡張機能のパッケージを作るタスクは、次のタスクで構成します。
- ビルドするタスク
- 必要なファイルをzipファイルにまとめるタスク
exports.pack = gulp.series(exports.build, exports['pack:zip']);
必要なファイルをzipファイルにまとめるタスク
当初のファイル構成では、単にプロジェクトフォルダーをzipファイルにすれば良かったのですが、zipファイルに含めたくないフォルダーやファイルが増えてきたため、この作業も手作業では面倒になってきます。
このタスクでは、gulp-zipを使います。
- zipファイルに何を含めるかは
gulp.src()
で指定します- 含めたくないファイルは
ignore
オプションで指定できます
- 含めたくないファイルは
- zipファイルの名前は
gulp-zip
の引数で指定します- ファイル名は
package.json
のname
とversion
から構成します
- ファイル名は
- zipファイルの出力先フォルダーは
gulp.dest()
で指定します- 出力先フォルダーはパッケージに含めないようにしましょう
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
でも除外しましょう。
node_modules/
dist/
+ artifacts/
Gulpで更新パッケージを作ってみる
ここまでのgulpfile.js
をまとめると、詳細のようになります。
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拡張機能のパッケージを作りたいときは、こうコマンドを実行します。
npx gulp pack
そうすると、artifacts/
の中にzipファイルができあがります。
更新パッケージをアップロードしてアイテムを公開するタスクを作る
更新パッケージを作れるようになったので、あとはそのパッケージを自動でアップロードしてChrome Web Storeにアイテムを公開したいところです。
Chrome Web StoreはAPIを提供しています。そのAPIを使えばアイテムをアップロード・公開するスクリプトを作れ、Gulpのタスクを作ることも可能です。
- Chrome Web Store API Reference - Google Chrome
- Using the Chrome Web Store Publish API - Google Chrome
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をインストールします。
npm i -D gulp-chrome-web-store
gulp-chrome-web-storeを使ってアイテムのアップロードと公開をするためには、まず設定が必要です。その設定に必要な認証情報とアクセストークンは機密情報なので、安全に取り扱わなければなりません。なので、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()
に渡してあげるだけです。
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()
を呼ぶだけです。
exports.publish = () => {
return item.publish();
}
Gulpでアイテムを公開してみる
ここまでのgulpfile.js
をまとめると、詳細のようになります。
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']);
更新パッケージをアップロードしてアイテムを公開したいときは、こうコマンドを実行します。
npx gulp --series deploy publish
ここまでの変更をコミットする
gulpfile.js
の編集が一段落したので、このあたりでここまでに追加・変更したファイルをコミットしておきましょう。
デプロイメントパイプラインを組む
Gulpを使ってアイテムを公開するまでの一連の作業を自動化できるようになりました。あとはその一連の作業をCI環境で実行できるようにデプロイメントパイプラインを組みます。GitHubリポジトリーのブランチにプッシュされるたびに必要なタスクが自動で実行されるようにします。
.circleci/config.yml
を作る
次の公式ドキュメントを参考にして.circleci/config.yml
を作ります。
version
フィールドの定義
commands
やexecutors
を使うので2.1
にします。
version: 2.1
Executorsの定義
Executors は、ジョブステップの実行環境を定義するものです。executor を 1 つ定義するだけで複数のジョブで再利用できます。
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
を使ってキャッシュを復元するステップは複数のジョブで使うのでコマンドとして定義して再利用できるようにしておきます。
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に公開する
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を定義します。
- まず
setup
して、その後に並行してtest
とlint
を行う - Gitタグが付いていたら、
test
の後にdeploy
する -
deploy
の後にhold
でapprovalを待つ -
approval
されたらpublish
する
アイテムを公開すると、Chrome Web Storeでの審査を待つことになります。審査結果が出るまでは、更新パッケージのアップロードはできなくなります。オペレーションを間違えて誤ったアイテムを公開しないために、publish
の前にApproval(手動の承認操作)を設けます。Approvalについては、承認後に処理を続行する Workflow の例を参照してください。
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
がインストールできたら、次のようにコマンドを実行します。
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
をこんな感じに修正します。
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.json
のpostversion
スクリプトにgit push --follow-tags
を仕込みます。
"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に公開してみましょう。
npm version patch
そうすると、GitHubにプッシュされ、それに連動してCircleCIでWorkflowが走ります。テストが正常終了して、それに続いて、更新パッケージのアップロードが成功したら、Workflowはapprovalされるまで一時停止します。Workflowをapprovalすれば、晴れてアイテムが公開されます。
まとめ
GulpとCircleCIを組み合わせて、Chrome拡張機能のリリースプロセスを自動化してみました。記事が長くなってしまいましたが、仕組みは汎用的なので、いろいろなChrome拡張機能の開発に活かせるものになっています。