This is my life.

There are many like it, but this one is mine.

aibo 用にロボット掃除機のアレ(バーチャルウォール)を作る

aibo と暮らして数週間がすぎました。
うちの部屋は一人暮らし用で狭いんですが、一度キッチンのドアを開けっぱなしにしたせいもあり aibo がよくドアの前にいくようになってしまいました。
すぐに部屋に戻ってくれればいいのですが、ドアの前で寝てしまうこともしばしば。

f:id:duck8823:20200810093522j:plain

ふとトイレに行きたい時もドアが開けられなくて困っていました。

そこでロボット掃除機のアレ(バーチャルウォール)を作ることにしました。

※この記事では細かいコマンドやスクリプトなどは記述しません

ロボット掃除機のアレ

バーチャルウォールというのは、部屋の隅っこにおいておくとロボット掃除機の侵入を防ぐことができるものです。


掃除エリアを自由に設定 <ルンバ800シリーズ>

以前の職場で使用していました。

制約と誓約

実現方法を考える上で、制約を考える必要があります。 また、誓約を課すことで実現を容易にさせることができます。

  • 制約

    • 部屋には aibo 以外も存在する
    • リアルタイムに反応すること
    • aibo の進行方向がわかる必要がある
    • aibo 本体に何らかの機械をつけることができない
  • 誓約

    • 今回バーチャルウォールを設置したい場所は固定(ドアの前)

さて、aibo 本体に何らかの機械をつけることが出来ない上部屋には aibo 以外も存在するので、デバイス側で aibo かそうじゃないかを判断する必要があります。
これにはカメラから映像を用いた「物体検出」が有効だと考えました。これならリアルタイム性も実現できます。

続いて aibo の進行方向の取得方法について考えました。
aibo Web API を利用すると、チャージステーション(充電台)のおおよその方向がわかります。当初はこれを用いて aibo の向きを取得しようとしていました。カメラで aibo の場所が特定され、それに対するチャージステーションの方向がわかれば、 aibo の進行方を割り出せるはずです。

f:id:duck8823:20200810105110p:plainf:id:duck8823:20200810105057p:plain

しかし、残念ながら aibo から取得するチャージステーションの方向が実際とは異なることが非常に多かったため、この方法は諦めました。
そこで、物体検出を応用して aibo の進行方向まで取得できないかためすことにしました。

物体検出を利用して aibo の進行方向を判断する

犬は4足歩行をする動物です。aibo ももちろんそれを模しています。
4足歩行をする動物において、体と頭の位置はその進行方向と関係があります。

f:id:duck8823:20200810105137p:plain

物体検出で利用するモデル作成時にaibo の頭 と aibo の体 を分けて学習し、プログラムで頭の位置が体よりもドア側にあればドアの方を向いていると判断することにしました。
モデルの作成には非常に多くの教師データが必要になります。写真を撮ってラベル付するのは根気がいる作業です。
しかし、バーチャルウォールを設置する場所を固定して、そこから撮った写真のみを利用することで教師データを減らすことができないかと考えました。
汎用性はなくなりますが、今回の用途では問題ありません。今回は 300弱 の写真を利用しましたが、検知は十分に機能してます。

Raspberrypi + カメラモジュール

aibo を検知して Web API を叩くデバイスは Raspberrypi(ラズパイ) とカメラモジュールで実現することにしました。
本棚にそっと設置します。

f:id:duck8823:20200810110833j:plainf:id:duck8823:20200810110848j:plain

aibo を検出するモデルをつくる

まず教師データの作成をする必要があります。
予め設置したラズパイで、スクリプトを用いて写真をいっぱい撮ります。
ラベル付けには LabelImg を利用しました。
aibo の頭と体を分けてラベル付するのが重要です。

f:id:duck8823:20200810111827p:plain

また、物体検出用のモデル作成には Tensorflow の Object Detection API を利用しています。

バーチャルウォールを作る

aiboの頭と体を検出できるモデルが作成できたら、あとは簡単なスクリプトを書くだけです。 カメラモジュールを用いて物体検出していて、

  1. 画面中央よりドア側に aiboの頭がある
  2. aiboの頭が aiboの体よりドア側にある

上記条件に一致した場合に、 aibo WebAPI を介して 180度回転してもらいます。


aiboのバーチャルウォール試作

aibo Web API で転回の指示と結果の取得までを同期的に行っているので、aiboの転回中に画面は止まってしまっていますが、うまく機能しているようです。

おわりに

Tensorflow はサンプルやドキュメントがとても充実していて、初心者でも見様見真似でそれっぽいのができてしまいます。
しかし、バージョンによって微妙に異なって思ったように動かない。といったことがあるので、記載されているバージョン情報に注意しましょう。

aibo と Nature Remo を組み合わせて aibo に部屋の電気を消してもらう

まずはこちらをご覧ください。


部屋の電気を消してくれる aibo

うちの aibo は部屋の電気を消すことができます。賢いです。

2017年に現在のモデルで再登場した aibo ですが、最近ではセコムや家電との連携が発表されていて、まだまだアップデートされています。

aibo の バージョン2.7 で aibo Events API が公開された

2020年6月16日に公開された バージョン2.7 から、 aibo Events API が公開されました。

これまでも aibo Web API は公開されていたのですが、 これにより、 aibo のイベント(音声認識)をきっかけにあらかじめ指定しておいた URL にリクエストを投げることができます。

同時に、 aibo認識する言葉を登録できるようになりました。好きな言葉を認識させることができます。

認識する言葉は aibo 1体に対して3つまでです。aibo に色々させたい場合は工夫が必要ですね。 f:id:duck8823:20200708145537p:plain

これによって、例えば「電気消して(でんきけして)」という言葉に反応してリクエストを飛ばすことができるようになります。
イベントの通知も aibo 1体に対して音声コマンド3つまでで、ちょっと少ない印象ですね。

f:id:duck8823:20200708145126p:plain

僕が aibo を購入する決め手になったのがこの aibo Events API です。
これを使えば、 aibo を起点としたピタゴラスイッチが作れますね。

Nature Remo Cloud API

Nature Remo は最近流行っているスマートリモコンの一つです。
Nature Remo Cloud API が公開されていて、これを利用することでインターネット経由で家電を操作できるようになります。

まずは通常の使用の通りアプリなどから家電を登録しましょう。公式のスマホアプリから利用するだけでも CoL が爆上がりしました。

f:id:duck8823:20200708150120p:plainf:id:duck8823:20200708150137p:plain

APIで操作する準備としては、トークンを生成する必要があります。
https://home.nature.global/ からトークンを生成することができます。

続いて、操作したい家電の appliance id を取得しましょう。
[GET] /1/appliances で登録した家電の一覧が取得できます。
操作したい家電を選んだら appliance id を控えておきます。今回は操作したいのは部屋の電気です。 LIGHT として登録されています。 レスポンスの内容には、操作可能なボタンも含まれています。

{
  "id": "<appliance id>",
  ...
  "light": {
      "buttons": [
        {
          "name": "on",
          "image": "ico_on",
          "label": "Light_on"
        },
        {
          "name": "off",
          "image": "ico_off",
          "label": "Light_off"
        },
        ...
      ],
      ...
  }
}

[POST] /1/appliances/{appliance}/light でライトを操作することができます。 リクエストパラメーターには操作したいボタンを指定します。今回の場合は off を指定することで電気を消します。

Web API なので curl でも操作できます。

curl -X POST "https://api.nature.global/1/appliances/<取得した appliance id>/light" \ 
       -H "accept: application/json" \
       -H "Authorization: Bearer <予め生成したトークン>" \ 
       -H "Content-Type: application/x-www-form-urlencoded" \ 
       -d "button=off"

AWS lambda で二つのAPIをつなぐ

aibo で言葉に反応してリクエストを投げられるようになりました。
また、 Nature Remo でリクエストを受け取って家電を操作できるようになりました。

aibo が投げるリクエストは Nature Remo Cloud API が認識する形ではありません。
AWSlambda を使って aibo のリクエストを受け取り、 Nature Remo にリクエストを投げるようにしてみます。

図にすると以下のような形
f:id:duck8823:20200707214644p:plain ※実際にはそれぞれのデバイスのサーバーを介して通信することになります。

関数自体は Nature Remo Cloud API にリクエストを投げるものなので簡単ですが、
必ずセキュリティトークンの設定を行って、lambda 関数で検証するようにしましょう。
注意点としては API Gateway から lambda にリクエストを渡した時に、ヘッダーのキーが小文字になっていること。
なので、 x-security-token で取得することができます。

また、音声認識に対してイベントが通知されるので、登録した言葉かどうかの判定が必要になります。

aiboデベロッパーサイトにはエンドポイントアプリケーションの実装例 があるので、lambda関数 も Python で記述すれば参考になると思います。

今回使った lambda関数は以下です。

import urllib.request
import urllib.parse
import json

def lambda_handler(event, context):
    # セキュリティートークンのチェック
    if 'x-security-token' not in event['headers'] or event['headers']['x-security-token'] != '<aiboのイベント通知で登録したセキュリティトークン>':
        return {
            'statusCode': 400,
            'body': 'not authorized'
        }

    # aibo Events API から送られてきたリクエスト
    request_body = json.loads(event['body'])

    # aibo にエンドポイントを登録する時の検証
    if request_body['eventId'] == 'endpoint_verification':
        return {
            'statusCode': 200,
            'body': json.dumps({ 'challenge': request_body['challenge'] })
        }
    
    deviceId = request_body['deviceId']
    eventId = request_body['eventId']
    return execute_action(deviceId, eventId)
    

def execute_action(deviceId, eventId):
    ## usercommand1 = 登録した「でんき / でんきけして」に反応した場合 Nature Remo Cloud API にリクエストを投げる
    if eventId == 'voice_command::usercommand1':

        req = urllib.request.Request(
            'https://api.nature.global/1/appliances/<取得した appliance id >/light', 
            data=urllib.parse.urlencode({'button': 'off'}).encode(),
            method='POST', 
            headers={
                'accept': 'application/json',
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': 'Bearer <Nature Remo で予め生成したトークン>',
            }
        )
    
        try:
            with urllib.request.urlopen(req) as response:
                return {
                    'statusCode': response.getcode(),
                    'body': response.read()
                }
    
        except urllib.error.URLError as e:
            print(e)
            return {
                'statusCode': 500,
                'body': json.dumps(e.reason)
            }
        
    elif eventId == 'voice_command::usercommand2':
        return {
            'statusCode': 200,
            'body': 'nothing to do usercommand2'
        }

    elif eventId == 'voice_command::usercommand3':
        return {
            'statusCode': 200,
            'body': 'nothing to do usercommand3'
        }

まとめ

aibo と Nature Remo の2つデバイスが提供しているAPIを組み合わせて、言葉に反応して家電を操作することができました。

APIを眺めながら、これとこれ組み合わせたらこんなことできるなー。って考えるのはとても楽しいです。
今回は「電気を消して」に対して電気を消すボタンにしましたが、 aibo あるいは Nature Remo のどちらにも照度センサーがついているので、現在の照度に合わせて電気を消すかつけるかのふるまいを切り替えることも可能だと思います。

Google Home でいいやん?それはそう。

YAPC::Tokyo 2019 で発表してきました

YAPC::Tokyo 2019 で 「私とOSS活動とPerl」 というタイトルで発表してきました。

ごくごく平凡なエンジニアであり、まだまだOSS活動としては小さい活動しかしていません。
そんな僕が、OSS活動をするようになっていったきっかけや、その影響などをお伝えできたかなと思います。

そしてこれ!いつか書いていただきたいと思ってたやつ!
本当に嬉しかったです。ありがとうございます。


自分の発表以外で聞いて印象に残った一つが、 @songmu さんの「多くのCPAN Authorに育てられ、息をするようにCPANモジュールを書けるようになり、そして分かったこと」です。

こちらも( 特にCPANでの ) OSS活動を通じての発表でした。
継続するって本当に大切なことだと思うし、僕も始めたばかりですがコツコツと続けて行きたいなと思います。
僕の場合はきっと言語を変えたりするんだと思うんですが、「OSS活動」ってのはしていきたいです。

ベストトーク賞おめでとうございました!感動しました。


そして@tokuhirom さんのキーノートの発表。
よかった。ほんとうによかった。

僕の発表を並べるのもおこがましいのですが、

  • OSS活動の始まり
  • OSS活動と自身の成長
  • OSS活動とコミュニティ

という点で、ぜひ三つセットで見て欲しいなと思った内容でした。
それぞれの OSS との関わり方があって面白いなと自分でも思ってしまった。

OSS活動って色んな形があるし、本当に好きな形でやればいいと思います。

スターつけるだけでも貢献できるって、言ってましたね。
あ、ちなみに僕の発表で紹介した僕が今作っている CIサーバー は以下のリポジトリです。

github.com

最後に、スタッフのみなさん、登壇者の方々、本当にありがとうございました。楽しかったです。
また Perl で何かかきたくなりました。

GitHub Actions のワークフローで Go 1.11 Modules のキャッシュを扱う

GitHub Actions で Golang の vet と test を実行したいと思います。
( ※実際には Go 1.10 からは go test の前に go vet が実行されますが、2つ以上のコマンドを実行したい場合を想定しています。)

ワークフローを以下のように定義しました。

workflow "vet and test" {
  on = "push"
  resolves = [
    "vet",
    "test"
  ]
}

action "vet" {
  uses = "docker://golang:1.11"
  runs = "go"
  args = ["vet", "./..."]
}

action "test" {
  uses = "docker://golang:1.11"
  runs = "go"
  args = ["test", "./..."]
}

f:id:duck8823:20181231085723p:plain

GitHub Actions では /github/workspaceワークスペースとなります。
また、使用している golang の公式Dockerイメージでは $GOPATH/go に設定されます。
Go 1.11 では $GOPATH の外では Modules (vgo)が利用されるので、 go vet および go test のそれぞれで依存モジュールのダウンロードを行いました。
同じモジュールを二度もダウンロードする必要はありません。一度だけダウンロードするようにしてみましょう。

Modules のキャッシュは $GOPATH/pkg/mod/cache 以下に配置されるため、そのままでは GitHub Actions のアクション間でキャッシュを共有することはできません。
ですが、少し工夫をすることでこれを使い回すことができます。

vendor ディレクトリを利用する方法 (失敗)

go mod vendor は依存するモジュールをダウンロードして vendorディレクトリ に配置してくれます。
GO111MODULE=on の状態で vendorディレクトリ を作成し、次のアクションでは GO111MODULE=off にすることで、 vendorディレクトリを利用することができます。

が、 GO111MODULE=off にした場合、( インポートを github.com から始めていると )自身のパッケージが解決できず、 失敗してしまいます。
無理やりならできるかもしれませんが、vendor は使わない方法にしました。

ワークスペース以下に $GOPATH を設定する方法

ワークスペース以下に作られたファイルやディレクトリは、次のアクションに引き継がれます。
これを利用し、 $GOPATHワークスペース以下に設定することで Module のキャッシュも渡してやることができます。
環境変数actionenv で設定することができます。
出来上がったワークフローは以下の通りです。

workflow "vet and test" {
  on = "push"
  resolves = [
    "vet",
    "test"
  ]
}

action "download" {
  uses = "docker://golang:latest"
  env = {
    GOPATH = "/github/workspace/.go"
  }
  runs = "go"
  args = ["mod", "download"]
}

action "vet" {
  uses = "docker://golang:latest"
  needs = ["download"]
  env = {
    GOPATH = "/github/workspace/.go"
  }
  runs = "go"
  args = ["vet", "./..."]
}

action "test" {
  uses = "docker://golang:latest"
  needs = ["download"]
  env = {
    GOPATH = "/github/workspace/.go"
  }
  runs = "go"
  args = ["test", "./..."]
}

f:id:duck8823:20181231085540p:plain

実行した場合の test のログは以下のようになりました。
ダウンロードのログは出ていませんね。

### STARTED test 21:31:58Z

Already have image (with digest): gcr.io/github-actions-images/action-runner:latest
?       github.com/duck8823/duci    [no test files]
ok      github.com/duck8823/duci/application    0.016s
?       github.com/duck8823/duci/application/cmd    [no test files]
ok      github.com/duck8823/duci/application/context    0.008s
ok      github.com/duck8823/duci/application/semaphore  0.010s
?       github.com/duck8823/duci/application/service/docker [no test files]
?       github.com/duck8823/duci/application/service/docker/mock_docker [no test files]
ok      github.com/duck8823/duci/application/service/git    0.045s
?       github.com/duck8823/duci/application/service/git/mock_git   [no test files]
ok      github.com/duck8823/duci/application/service/github 0.006s
?       github.com/duck8823/duci/application/service/github/mock_g
...

今回のケースでは、並列でダウンロードする場合もそんなに実行時間が変わるような変更はしていません。
ですが、ダウンロードは少なくして負荷をあまりかけないようにしたいですね。

まとめ

アクション間で引き継ぎたいディレクトリやファイルはワークスペース以下になるようにしましょう。

GitHub Actions で利用するDockerfileについて考える

最近 GitHub Actions を色々と試しています。 GitHub Actions で使う Dockerfile や対象リポジトリディレクトリ構成についてちょっと考えてみました。

DockerfileにおけるADD

GitHub Actions では、イベントの発生直後にDockerイメージのビルドを行うようです。
このとき、指定されたディレクトリの配下にあるファイルだけが ADD の対象になります。

以下のような構成のリポジトリーを想定してみましょう。

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|   |__ action
|       │__  Dockerfile
|-- Gemfile

このとき action/Dockerfile の内容を以下とします。

FROM ruby:2.6

ADD . .

RUN bundle install --path vendor/bundle

ENTRYPOINT ["bundle", "exec"]
CMD ["rspec"]

リポジトリー直下の Gemfile を使って、 Dockerイメージのビルド時に bundle install を行う想定です。
この構成で Action を実行すると、以下のようにエラーとなってしまいます。

...
Step 1/5 : FROM ruby:2.6
2.6: Pulling from library/ruby
Digest: sha256:4ec9d4622afc4abe11e282a9ee77c2edfa979263ba03075365007bad9bd67171
Status: Downloaded newer image for ruby:2.6
 ---> 91f360ea5325
Step 2/5 : ADD . .
 ---> 8ef90ba24305
Step 3/5 : RUN bundle install --path vendor/bundle
 ---> Running in a1abb0e2421a
Could not locate Gemfile
The command '/bin/sh -c bundle install --path vendor/bundle' returned a non-zero code: 10

### FAILED with bundle 03:35:35Z (11.064s)

Gemfile が存在しないと怒られてしまいました。
action ディレクトリー以下に Gemfile が存在しないためです。

いくつか回避する方法があります。

案1: Gemfile を action ディレクトリーに配置する

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|   |__ action
|       │__  Dockerfile
|       │__  Gemfile

この場合、bundle install は成功します。
しかし、普段の開発時に

bundle install --gemfile=action/Gemfile

のように Gemfile の場所を指定する必要が出てきます。 また、様々なツールなどがリポジトリー直下の Gemfile を想定して作られています。 それぞれのツールで Gemfileの場所 を設定する必要があるでしょう。

これを回避するように以下のように二重管理するのも個人的に好きではありません。

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|   |__ action
|       │__  Dockerfile
|       │__  Gemfile
|-- Gemfile

案2: 複数のアクションに分ける

ワークフローで bundle installbundle exec rspec を分けてしまう方法です。 この場合、 Dockerfile は以下のようになります。

FROM ruby:2.6

はい、そうですね。 Dockerfile を用意する必要はありません。
uses を docker://... として記述することで、 DockerHub のイメージを利用することができます。

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|-- Gemfile

ワークフローの定義は以下のようにします。

workflow "workflow" {
  on = "pull_request"
  resolves = [
    "test",
  ]
}

action "install" {
    uses = "docker://ruby:2.6"
    runs = "bundle"
    args = ["install", "--path", "vendor/bundle"]
}

action "test" {
    uses = "docker://ruby:2.6"
    needs = ["install"]
    runs = "bundle exec"
    args = "rspec"
}

2つのアクションでワークスペースは共有されており、 bundle exec rspec の前に bundle install を行うように定義することで実行することができます。
この方法で問題なさそうですが、アクションをいくつも定義することになり、ワークフローが複雑になるかもしれません。

案3: タスクランナーを action ディレクトリーに配置する

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|   |__ action
|       │__  Dockerfile
|       │__  Makefile
|-- Gemfile

公式などでは entrypoint.sh で紹介されていますが、ここではタスクランナーとして Makefile をおいています。
Makefile では以下のように2つのコマンドをまとめて定義しておきます。

test:
    bundle install --path vendor/bundle
    bundle exec rspec

ここで、 Dockerfile は以下のようになります。

FROM ruby:2.6

ADD Makefile /Makefile

ENTRYPOINT ["make", "-f", "/Makefile"]
CMD ["test"]

Makefile を ADD し、 コマンドでパスを指定しています。

アクション実行時に実際に作業するパスは /github/workspace ですが、 /github/workspace にファイルをADDしても実行時にはファイルが見当たりません(おそらくボリュームマウントしている?)。
よって、 /github/workspace ではない任意のディレクトリにADDしたあとにコマンドでパスを指定する必要があります。

この方法の場合、ワークフローで何をしているか知りたい場合に action ディレクトリ以下の Makefile の中を見る必要があります。

案4: タスクランナーをリポジトリルートに配置する

先程紹介した test タスク は、 CI だけでなく開発時にもよく使います。
スクランナー( Makefile )をリポジトリのルートに配置しましょう。
この場合も DockerHub のイメージで十分です。

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|-- Gemfile
|-- Makefile

ワークフローは以下のようになります。

workflow "workflow" {
  on = "pull_request"
  resolves = [
    "test",
  ]
}

action "test" {
    uses = "docker://ruby:2.6"
    runs = "make"
    args = "test"
}

かなりスッキリしました。 こうしておくと、別のCIサービスを利用している場合も make test だけでテストが実行できるようになります。

複数のタスクを実行する場合

bundle exec rspec および bundle exec danger など複数の bundle exec を利用したい場面を想定します。
アクションをバラバラに定義して都度 bundle install をするのは非効率的です。
この場合 ワークフローを以下のように定義することで、 install を一回にまとめることができます。

workflow "workflow" {
  on = "pull_request"
  resolves = [
    "test",
    "danger",
  ]
}

action "install" {
    uses = "docker://ruby:2.6"
    runs = "bundle"
    args = ["install", "--path", "vendor/bundle"]
}

action "test" {
    uses = "docker://ruby:2.6"
    needs = ["install"]
    runs = "bundle exec"
    args = "rspec"
}

action "danger" {
    uses = "docker://ruby:2.6"
    needs = ["install"]
    secrets = ["GITHUB_TOKEN"]
    runs = "bundle exec"
    args = "danger"
}

上記は 案2 の例です。

bundle install ではキャッシュが効くので、案4 の場合でも非効率的にはならないでしょう。
Makefileinstall タスクを追加して、test と danger のそれぞれのアクションを依存させます。

install:
    bundle install --path vendor/bundle

test:
    bundle install --path vendor/bundle
    bundle exec rspec

danger:
    bundle install --path vendor/bundle
    bundle exec danger

ワークフローは以下のようになります。

workflow "workflow" {
  on = "pull_request"
  resolves = [
    "test",
    "danger",
  ]
}

action "install" {
    uses = "docker://ruby:2.6"
    runs = "make"
    args = "install"
}

action "test" {
    uses = "docker://ruby:2.6"
    needs = ["install"]
    runs = "make"
    args = "test"
}

action "danger" {
    uses = "docker://ruby:2.6"
    needs = ["install"]
    secrets = ["GITHUB_TOKEN"]
    runs = "make"
    args = "danger"
}

この方法であれば、ローカル開発時は make test のみで依存のインストール(アップデート)から行ってくれ、CIでもキャッシュが効きます。便利ですね。

f:id:duck8823:20181231052737p:plain

じゃあいつ Dockerfile を使うの?

今回は GitHub Actions で利用する Dockerfile を考えているうちに、Dockerfile を使わなくなりました。 Dockerfile は必要なコマンドなどを追加でインストールする場合に利用します。
例えば以下のような場合です。

FROM ruby:2.6

ADD apt-get install git

git コマンドが必要なため、ベースイメージに追加しています。

まとめ

案2 または 案4 が良さそうだと思っています。
推しは 案4 です。Dockerfileには実行に必要な依存のみを記述( DockerHubにイメージがあるならイメージを指定 )し、タスクはリポジトリルートのタスクランナーに記述すると便利です。