Vim の構文ハイライトでクリスマスツリー🎄を飾ってメリクリする
Vim Advent Calendar 2018 の24日目の記事です.昨日は Kaoriya さんのVim に VOICEROID で喋らせたでした.
もうすぐクリスマスなので,クリスマスツリーを飾りたいと思います.ただ飾るだけだと Vim のネタにならないので,Crystal や Wast,vim-gfm-sytnax, vim-github-actions といったファイルタイププラグインをつくった経験を活かし,構文ハイライトを使って Vim の中でクリスマスツリーを飾っていきたいと思います.
Vim の構文ハイライトについての情報は下記の help ドキュメントを読んでいただければ,ある程度網羅的な情報が手に入るので,実際に何かのファイルタイプを追加するプラグイン(ファイルタイププラグイン)を実装する時は,まずざっと一通りドキュメントを眺めることをおすすめします.
この記事はまだファイルタイプを書いたことが無い人を対象とします.網羅的な情報ではなく,ハイライトをつけていく過程を実例とともにコードで説明して,ファイルタイププラグインを実装する大まかな手順を把握していただくのを目的として進めていきます.今回はインデントについては時間の都合(や題材の都合)で省略します.
今回使ったコードはすべてこのリポジトリにあります.
ファイルタイププラグインとは
本題に入る前にファイルタイププラグインを知らない方向けにざっくりとした説明をしておきます.
特定の拡張子のファイルを開いたときなど,set filetype=...
のようにファイルタイプをセットしたときにそのファイルタイプの構文ハイライトやインデント設定などを行ってくれるプラグインをファイルタイププラグインと呼びます(:help filetype-plugin
).
Vim は c
や vim
をはじめ,デフォルトで多くのファイルタイプをサポートしていますが,自分で新しいファイルタイプを足すこともできます.有名どころだと typescript-vim や vim-toml など無数にあります.
また,vim-css3-syntax や vim-jsx-pretty, vim-gfm-syntax のように,自前でファイルタイプを定義するのではなく,既存のハイライトに独自のハイライトを足すようなプラグインもあります.
下記のような場合にファイルタイププラグインを自作する必要があります
- Vim が公式で対応していない言語のコードをハイライトしたい(e.g. 自作言語,自作設定ファイル)
- 既存のファイルタイププラグインではうまく動かない(or メンテされていない)などの事情で,自分でつくりたい
- アプリのログを読むときに重要な箇所をハイライトしたい
0. まずは対象の仕様を把握する
ファイルタイププラグインを書き始める前に,まずは対象を把握します.特定の言語のファイルタイププラグインをつくる場合は,まずはその言語の構文の仕様を眺めたりします.例えば vim-wasm をつくる際には WebAssembly の Text Format の仕様を読んだりしました.
今回はクリスマスなので,下記のクリスマスツリーをハイライト(飾り付け)していきたいと思います.
asciiart.eu の Laura T さんの作品をベースにしてつくってみました.
* ☆ * * .o * * * .o.'. .'.'o'. * * o'.*.'.o. * .'.o.'.'.*. * * * .*.'.o.'.o.'. * * o'.'.'.'*'.'.'. * * .'.'*'.'o'.'.'*'o * * [_____] * * \___/ * * Merry Christmas!
ツリーのてっぺんには☆が飾られており,o
や *
で装飾された木だと思ってください.周りの *
はクリスマスなので雪が降っています.このままでも綺麗ですが(?),色を付けて飾りつけていきましょう.
新しいファイルタイプとして christmastree
を定義することにします.このファイルタイプが定義されている時は Vim はバッファにクリスマスツリーが描かれているとしてハイライトするようにこれから実装していきます.
1. リポジトリとファイルタイプの認識
まずは Vim プラグインのリポジトリを用意します.ディレクトリを runtimepath
に直接追加する(.vimrc
内で set rtp+=/path/to/vim-syntax-christmas-tree
)か,お好みのプラグインマネージャでロードするプラグインのリストに追加してください.
mkdir vim-syntax-christmas-tree && cd vim-syntax-christmas-tree git init .
ディレクトリ構成はこのようになっています. christmas-tree.txt
は上記のアスキーアートが描かれたテキストファイルです.
vim-syntax-christmas-tree/ ├── christmas-tree.txt ├── ftdetect/ │ └── christmastree.vim ├── syntax/
まずはファイルタイプを認識させます.Vim は ftdetect/*.vim
の中身を起動時にロードしてくれるので,ftdetect/christmastree.vim
をつくり,この中にファイルタイプを認識するための :autocmd
を書きます.
autocmd BufNewFile,BufReadPost christmas-tree.txt setlocal filetype=christmastree
今回は christmas-tree.txt
という固定のファイル名のファイルを読んだときのみ,ファイルタイプ christmastree
をセットするようにしています.実際は TypeScript なら *.ts
のように拡張子でパターンを指定することが多いです.
ここで一旦 Vim を新たに開き,christmas-tree.txt
を開いて :set ft
で今のファイルタイプが christmastree
になっているのを確認します.
2. 構文ハイライトファイルをつくる
Vim は構文ハイライトファイルとして syntax/{filetype}.vim
か syntax/{filetype}/*.vim
を探すようになっているので,syntax/christmastree.vim
をつくります.
まだ何もハイライトしない空の状態です.
if exists("b:current_syntax") finish endif " ここにハイライト処理の実装を書いていく let b:current_syntax = "christmastree"
変数 b:current_syntax
はそのバッファをハイライトしている構文の名前です.すでにハイライト済みかどうかはその変数があるかどうかでチェックし,まだハイライトされていなければハイライト処理を行うという構造になっています.ハイライトを行った後は,すでにハイライトしたということが分かるように,b:current_syntax
を定義しておきます.
3. はじめてのハイライト: :syn match
まずはてっぺんの☆をハイライトしてみます.
これは単に ☆
という文字列を色をつけてハイライトすれば良さそうです.
syn match christmastreeStar "☆" hi def link christmastreeStar Identifier
:syn match {ハイライトグループ} {パターン}
を使っています.これにより,指定したパターン(正規表現)にマッチする文字列を指定したハイライトグループでハイライトできます.
ここでは christmastreeStar
というハイライトグループをつくることにし,"☆"
というパターン(正規表現)にマッチする箇所とそのハイライトグループを紐づけます.この時点ではハイライトグループを紐づけただけなのでまだ色はつきません.ハイライトグループ名は小文字始まりのファイルタイプ名をプレフィックスとしてつけます.これはハイライトグループ名が一意でないといけない(他とかぶると上書きされてしまう)ためです.
次の行の hi def link {ハイライトグループ} {別のハイライトグループ}
という行で,christmastreeStar
を Identifier
というハイライトグループに紐づけています.
Vim はデフォルトでいくつかのハイライトグループを定義しています.例えば (Normal
: 通常のテキスト,Visual
: ビジュアルモード選択 など).また,習慣として,同じ構文を同じグループにまとめたものが推奨されるハイライトグループ(Identifier
: 変数名,Statement
: 文 など)として明記されています.いずれも :help :hi
で一覧を確認できるので,ざっと見ておいたほうが良いです.カラースキームプラグインはこれらの推奨ハイライトグループやデフォルトのハイライトグループに対して色を指定することで好きな色合いを実現しています.(カラースキームについては,もしよければフルスクラッチからさいきょうの Vim カラースキームをつくろう!を参照してみてください)
Vim で :hi
と打つと今のハイライトグループとそれに指定されている色がリストで表示されるので,そこで実際の色を確認できます.
:hi def link
はデフォルトでハイライトグループを別のハイライトグループにリンクします.なので,ここでは christmastreeStar
というハイライトグループにマッチするテキストは Identifier
と同じ色でハイライトされます.
実際にプログラミング言語のハイライトを書く場合は,対象の構文が何にあたるかでリンクするハイライトグループを決めます.例えば if
文なら Statement
,関数定義なら Function
など.今回は残念ながら(?)クリスマスツリーに適した推奨ハイライトグループが無いため,適当に手元のカラースキームで合いそうな色を選択しました.
ちなみに
syn match Identifier "☆"
のように新しいハイライトグループを定義せず直接共通のハイライトグループを指定することもできますが,カラースキームがハイライト色を上書きできなくなるためやめておいたほうが良いです.:hi def
はすでにハイライトがリンクされているときに何もしないので,カラースキーム側で先に :hi link
してやることで,ハイライト色を上書きできるようになります.
実際に Vim で開いて確認してみます.:term ++close vim christmas-tree.txt
とすることで,Vim の中で直接 Vim を開いて最新のハイライトを確認できます.
やりました! ☆
がハイライトされていることが分かります.
ちなみに本来は短い特定の文字列は :syn keyword
でキーワードとしてハイライトするのが一般的ですが,:syn keyword
は 'iskeyword'
に指定されている文字でないとハイライトしてくれないので,☆
には使えず :syn match
を使いました.
4. 葉をハイライトする: :syn region
次にツリーの葉を緑でハイライトしてみます.先程の :syn match
を使っても良いですが,今回は :syn region
を使ってハイライトします.周りには雪が舞っていてそれらは葉に含めたくないので,行ごとにハイライトするのが良さそうです.
syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ oneline hi def link christmastreeLeaves String
新たなハイライトグループ christmastreeLeaves
を定義しています. syn region
はハイライトするテキストの始め(start=
)と終わり(end=
)によってハイライトする範囲を指定できます.始まりと終わりがダブルクォートな文字列リテラル("..."
)や,{...}
によるブロック構文などは :syn region
を使うことが多いです.
ここでは,木は .
か o
で始まり,.
か o
で終わっていることに目をつけて,空白の後に .
か o
が続く場合を始めとし,空白または改行に続く .
か o
を終端とします.
最後に,葉は行ごとにハイライトすると決めたので,oneline
オプションを指定しておきます.これによって,ハイライトが次の行に継続してハイライトされることがなくなります.
実際に Vim で開いて確認してみます.
無事,意図通り緑色(私のローカルのカラースキームでは文字列が緑なので String
を指定している)でハイライトされています.少し木っぽくなってきた気がします.
5. ハイライトをネストさせる: contained
, containedin=
, contains=
飾り *
をハイライトしてみます.☆
のとき同様に *
にマッチするハイライトを新しいハイライトグループ christmastreeGlitter
で指定し,
syn match christmastreeGlitter /\*/ hi def link christmastreeGlitter Constant
実際に Vim で開いて確認してみます.
雪のほうがハイライトされてしまいました.一方でハイライトしたほうの木の *
のほうはハイライトされていません.これは,葉のハイライトのほうが優先されてしまっているためです.
ここでやりたいことは,葉の中の *
だけをハイライトするということです.
これはハイライトを「ネストさせる」ことで実現できます.Vim のハイライトは正規表現でマッチする領域を重ねた層のような構造になっており,contains=
, containedin=
, contained
などの :syn
のオプションでそれらを制御します.
今回の例では
syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=christmastreeGlitter oneline keepend syn match christmastreeGlitter /\*/ contained containedin=christmastreeLeaves hi def link christmastreeLeaves String hi def link christmastreeGlitter Constant
のようにすることで実現できます.
contained
をつけたハイライトはトップレベルではハイライトされなくなり,後述の contains=
でネストしてマッチした場合のみハイライトされるようになります.これによって,外の雪は白いままになります.
ハイライトをネストさせるには,別のハイライトを含む側に contains=
で含むハイライトのグループ名をコンマ区切りで書き,含まれる側のハイライトに含むハイライトグループを containedin=
で指定します. containedin=
は無くても良いですが,あったほうが意図しないハイライトを防げて良いと思います.
実際に Vim で開いて確認してみます.
無事,木の飾りの *
だけに色をつけることができました.
実際のファイルタイププラグインでは,このようにハイライトをネストさせることで特定の構文のみに適用されるハイライトを定義します.例えば if
文が関数ブロックの中でしか書けないような言語であれば,関数ブロック全体にマッチするハイライトを定義し,その中にネストして if
文のハイライトをマッチさせるような実装が考えられます.
ちなみに,しれっと keepend
というオプションを足していることに気付いたでしょうか?:syn region
は同じ構文でネストしたハイライトをうまく扱う(例えば { { ... } }
のようにネストしていても内側と外側にうまくハイライトマッチできる)ような挙動になっているのですが,これだと外側(christmastreeLeaves
)のマッチするテキストの末尾で内側(christmastreeGlitter
)が同時にマッチするとき,内側が優先されて外側のマッチが終了しなくなります.この挙動を変えるのが keepend
で,内側のハイライトと外側のハイライトが同時に終了するようになります.
6. 複数のハイライトをまとめる
木の飾りは *
と o
の2種類あります.それぞれに違う色を割り当てられたほうが華やかになるので,o
は *
とは別のハイライトを割り当てます.
ここで先程のセクションと同様にして o
もハイライトしても良いですが,せっかくなので1つの「クラスタ」にまとめることにします.
複数のハイライトグループをひとまとめにする :syn cluster
を使います.
syn cluster christmastreeGlitters contains=christmastreeGlitterSmall,christmastreeGlitterLarge syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=@christmastreeGlitters oneline keepend syn match christmastreeGlitterLarge /o/ contained containedin=christmastreeLeaves syn match christmastreeGlitterSmall /\*/ contained containedin=christmastreeLeaves hi def link christmastreeLeaves String hi def link christmastreeGlitterSmall Constant hi def link christmastreeGlitterLarge Special
まずは *
を christmastreeGlitterSmall
,o
を christmastreeGlitterLarge
として別のハイライトグループにし,それぞれに別のハイライト色を指定できるようにします.
次に :syn cluster {クラスタ名} contains={コンマ区切りのハイライトグループ}
を使って @christmastreeGlitters
をひとまとめにします.
クラスタは :syn region
の contains=
のように,コンマ区切りで複数のハイライトグループを指定する箇所で使えます.
実際に Vim で開いて確認してみます.
今回の例では,まとめずに *
と o
で別個にハイライトを指定するのに比べてあまり嬉しさが分かりませんが,数が増えたり,階層構造が深くなったりしたときに管理がかなり楽になります.
実際のファイルタイププラグインでは,例えば関数ブロックの中に含められるハイライトグループをひとまとめにして1つのクラスタにしたりといった管理をしているのを見ます.vim-wasmでは,WebAssembly のテキストフォーマットはS式で記述するので,(...)
で囲まれた中のみでハイライトが効く(逆に括弧の外であるトップレベルではコメント以外ハイライトされない)ように,括弧の中だけでハイライトすべきハイライトグループを1つのクラスタにしています.
7. 完成
最後に忘れていた植木鉢を :syn match
でハイライトさせて完成です.[___]
と \___/
に分けてマッチさせていますが,それぞれ同じ構文の一部として同じハイライトグループ名 christmastreeFlowerpot
を指定しています.
このように,1つのハイライトグループを複数の :syn
で構成することもできます.
これでようやく一通り飾り終えることができました. syntax/christmastree.vim
の全体像は下記の通りです.
if exists("b:current_syntax") finish endif syn cluster christmastreeGlitters contains=christmastreeGlitterSmall,christmastreeGlitterLarge syn match christmastreeStar /☆/ syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=@christmastreeGlitters oneline keepend syn match christmastreeGlitterLarge /o/ contained containedin=christmastreeLeaves syn match christmastreeGlitterSmall /\*/ contained containedin=christmastreeLeaves syn match christmastreeFlowerpot /\[_\+\]/ syn match christmastreeFlowerpot /\\_\+\// hi def link christmastreeStar Identifier hi def link christmastreeLeaves String hi def link christmastreeGlitterSmall Constant hi def link christmastreeGlitterLarge Special hi def link christmastreeFlowerpot PreProc let b:current_syntax = "christmastree"
実際に Vim で開いて確認してみます.
無事綺麗に飾りつけられました.お疲れ様でした.
8. さらにピカピカさせる
構文ハイライトだけでも綺麗ですが,ftplugin
を使ってさらに華やかにしてみます.
Vim はファイルタイプをセットしたときに ftplugin/{filetype}.vim
または ftplugin/{filetype}/*.vim
を読みに行くため,そのファイルの中にコードを書いておけば,特定のファイルタイプをセットしたときに処理を行うことができます.
今回は ftplugin/christmastree.vim
を作成し,構文ハイライトの b:current_syntax
のように,もう ftplugin
を読み終えたことを示す b:did_ftplugin
をチェックする処理を最初に書きます.これにより,意図せず複数の ftplugin
スクリプトが読まれてしまうことを防ぎます.
こうすることで,例えば別の ftplugin
を作成して既存の ftplugin
を置き換えると言ったことができるようになります.
if exists("b:did_ftplugin") finish endif let b:did_ftplugin = 1
今回はタイマーを使って,一定時間ごとに飾りの色が変わるようにしてみました.
function! s:christmas_glitter_tick(timer) abort let small = get(b:, 'christmastree_glitter_small_colors', ['Constant', 'Statement', 'Special', 'Ignore']) let large = get(b:, 'christmastree_glitter_large_colors', ['Special', 'Keyword', 'Identifier', 'Normal', 'Statement', 'Constant', 'Ignore']) let idx = s:tick % len(small) execute 'hi link christmastreeGlitterSmall' small[idx] let idx = s:tick % len(large) execute 'hi link christmastreeGlitterLarge' large[idx] let s:tick += 1 endfunction function! s:christmas_glitter_start() abort if exists('s:timer_id') return endif let s:tick = 1 let s:timer_id = timer_start(1000, function('s:christmas_glitter_tick'), {'repeat': -1}) endfunction function! s:christmas_glitter_stop() abort if !exists('s:timer_id') return endif call timer_stop(s:timer_id) unlet! s:timer_id unlet! s:tick endfunction if get(g:, 'christmastree_glitter_update', 1) call s:christmas_glitter_start() autocmd BufWipeout <buffer> call <SID>christmas_glitter_stop() endif command! -nargs=0 -bar -buffer ChristmasTreeTurnOn call <SID>christmas_glitter_start() command! -nargs=0 -bar -buffer ChristmasTreeTurnOff call <SID>christmas_glitter_stop()
詳しくは説明しませんが,timer_start()
を使って1秒毎に christmastreeGlitterSmall
と christmastreeGlitterLarge
のリンクするハイライトグループを書き換えることでハイライト色を変更しています.
JavaScript の setInterval
のようにタイマーは非同期に呼ばれるので,s:christmas_glitter_tick()
が実行されるわずかな時間しかユーザの操作をブロックしません.
なので,Vim の中にクリスマスツリーをピカピカ光らせながら普段のコーディングができます.
ftplugin/christmastree.vim
はファイルタイプ christmastree
がセットされるたびに毎回読まれるので,その中に直に処理を書けば良く, g:christmastree_glitter_update
が 0
にセットされていないときのみタイマーを開始するようにしています.
また,手動でタイマーを止めたり開始したりできるように,:ChristmasTreeTurnOn
と :ChristmasTreeTurnOff
も定義しています.-buffer
付きで定義しているので,そのバッファ内のみで使えるコマンドとして定義されます.
これでクリスマスの準備もバッチリですね!(?)
GitHub Actions (beta) が使えるようになったので調査した
GitHub Actions Open Beta に申し込んで1ヶ月以上経ち,ようやく使えるようになったみたいなので実際にどう使うのか調査してみたメモ. Beta 版は GitHub のプライベートリポジトリにしか使えないため,公開リポジトリに使うにはもう少し待つ必要がありそう.
GitHub Actions とは
GitHub Universe 2018 で発表されたときにメディアが記事にしているので,そちらを読んでください.
- https://www.publickey1.jp/blog/18/github_actionsdockergithub_universe_2018.html
- https://cloud.watch.impress.co.jp/docs/event/1148428.html
- https://codezine.jp/article/detail/11170
公式ドキュメント
hello world 的なのは下記のリンクから action をつくるチュートリアルと workflow をつくるチュートリアルをやれば良いです.
全体的な流れは,
Dockerfile
とentrypoint.sh
をつくって欲しいアクションを定義する(プリセットのアクションしか使わない場合はこのステップは不要)- GitHub の Actions タブに行き,workflow をビジュアルエディタで定義する.ワークフローの起点と,そこからのアクションの連なりをグラフエディタでつくれる.直接
.github/
以下を書いても良いけど,こっちのほうがチェックもしてくれるし良さそう - 実際に起点となるイベントを起こしてみる(例えば
git push
) - 再び Actions タブに行くと,各ワークフローがどう走って結果がどうだったか(ログなど)が確認できる
- ワークフローを編集したい時は
.github/
以下の.workflow
ファイルの Edit ボタンをクリックすると再びビジュアルエディタが立ち上がる
参考リンク:
- GitHub Actions
- About GitHub Actions
- Creating a workflow with GitHub Actions
- Creating workflows
- Workflow configuration options
基本
各アクションはアクションを走らせる Docker コンテナのための Dockerfile
と処理のエントリポイントになるシェルスクリプト entrypoint.sh
の組み合わせで定義できます.
Dockerfile
のラベルでメタ情報(description とか action name とか)を記述します.実際の処理は entrypoint.sh
(もしくは entrypoint.sh
から呼ばれるスクリプトなど)でシェルスクリプトで定義します.依存しているツール(例えば JSON を扱うなら jq
とか)は Dockerfile
をビルドするときにコンテナに apt install
などでインストールしておきます.
コンテナ実行時の情報はスクリプトの引数か,環境変数で与えられます.環境変数はアクションをワークフローエディタで編集する時に指定でき,スクリプト側からそれらが参照できます.またスクリプトの引数もアクションの編集で指定でき,entrypoint.sh
の引数としてアクション側に渡ってきます.秘密の情報 (secrets) はリポジトリページの settings から secrets タブを選択してキー・バリューで秘密の情報を入力しておき,アクションの設定でどのキーを使うかを指定しておくと,Docker コンテナから環境変数としてそれらが見える?ようです(env
コマンドの出力はフィルタされているのか確認できなかった)
アクションの起点は GitHub の公式ドキュメントに乗っている一覧のイベント から選べます.例えば on: "push"
と指定するとコミットをプッシュするたびにワークフローが走ります.
どうやってアクションを書くのか
注:特に明記しない限り,実動作ベースでの調査をしたので,今後動作が変わったり,勘違いしている箇所があるかもしれません
公式のアクション
actions organization に各アクションごとにリポジトリが置かれているので,それを参考にできます.
アクションが実行される環境
pwd
で確認すると,アクションのエントリポイントとなる entrypoint.sh
は /github/workspace
というディレクトリ内で実行されています.
このディレクトリについては下記の公式ドキュメントに詳細がありました:
どうやら対象のリポジトリのルートディレクトリになっているらしいです.試しに ls -la
してみると
total 24 drwxr-xr-x 6 root root 4096 Dec 9 13:54 . drwxr-xr-x 5 root root 4096 Dec 9 13:55 .. drwxr-xr-x 8 root root 4096 Dec 9 13:54 .git drwxr-xr-x 2 root root 4096 Dec 9 13:54 .github drwxr-xr-x 2 root root 4096 Dec 9 13:54 action-a drwxr-xr-x 2 root root 4096 Dec 9 13:54 action-b
.git
ディレクトリが置かれており,リポジトリのルートにいると分かります.
すでにリポジトリはクローンされた状態で実行されるので,自前で対象のリポジトリをクローンしてくる必要は無さそうです.
アクション内で参照できる環境変数
どうやら $GITHUB_*
という環境変数に情報が入っているようです.一覧は公式ドキュメントにあり,
例えば,on: "push"
でリポジトリへの push を行った際の環境変数は下記です:
GITHUB_EVENT_PATH=/github/workflow/event.json GITHUB_WORKFLOW=hello GITHUB_ACTION=Hello World GITHUB_REPOSITORY=rhysd/hello-github-actions GITHUB_WORKSPACE=/github/workspace GITHUB_SHA=52875f0b1ed9882770c0cfddbcfe95607e4b2986 GITHUB_ACTOR=rhysd GITHUB_REF=refs/heads/master GITHUB_EVENT_NAME=push
これでどのワークフローやアクションとして自身が実行されているかを知ることができます.
アクション内で参照できるフックイベントの情報
ちなみに $GITHUB_EVENT_PATH
の /github/workflow/event.json
には起点になったイベントの情報が JSON で入っています.各イベントごとに入っている情報はGitHub の公式ドキュメントで知ることができます.詳細なイベントフックの情報が欲しい場合はこっちを見たほうが良さそうです.
例えば on: "push"
での event.json
の中身は下記です:
{ "after": "c776e7146a031950fa579791f71448336a07880c", "base_ref": null, "before": "52875f0b1ed9882770c0cfddbcfe95607e4b2986", "commits": [ { "added": [], "author": { "email": "my-email@example.com", "name": "rhysd", "username": "rhysd" }, "committer": { "email": "my-email@example.com", "name": "rhysd", "username": "rhysd" }, "distinct": true, "id": "c776e7146a031950fa579791f71448336a07880c", "message": "check event.json", "modified": [ "action-a/entrypoint.sh" ], "removed": [], "timestamp": "2018-12-09T21:39:47+09:00", "tree_id": "7d4c96c54a304cd3af1bdbac684099bbbeb1dcc9", "url": "https://github.com/rhysd/hello-github-actions/commit/c776e7146a031950fa579791f71448336a07880c" } ], "compare": "https://github.com/rhysd/hello-github-actions/compare/52875f0b1ed9...c776e7146a03", "created": false, "deleted": false, "forced": false, "head_commit": { "added": [], "author": { "email": "my-email@example.com", "name": "rhysd", "username": "rhysd" }, "committer": { "email": "my-email@example.com", "name": "rhysd", "username": "rhysd" }, "distinct": true, "id": "c776e7146a031950fa579791f71448336a07880c", "message": "check event.json", "modified": [ "action-a/entrypoint.sh" ], "removed": [], "timestamp": "2018-12-09T21:39:47+09:00", "tree_id": "7d4c96c54a304cd3af1bdbac684099bbbeb1dcc9", "url": "https://github.com/rhysd/hello-github-actions/commit/c776e7146a031950fa579791f71448336a07880c" }, "pusher": { "email": "rhysd@users.noreply.github.com", "name": "rhysd" }, "ref": "refs/heads/master", "repository": { "archive_url": "https://api.github.com/repos/rhysd/hello-github-actions/{archive_format}{/ref}", "archived": false, "assignees_url": "https://api.github.com/repos/rhysd/hello-github-actions/assignees{/user}", "blobs_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/blobs{/sha}", "branches_url": "https://api.github.com/repos/rhysd/hello-github-actions/branches{/branch}", "clone_url": "https://github.com/rhysd/hello-github-actions.git", "collaborators_url": "https://api.github.com/repos/rhysd/hello-github-actions/collaborators{/collaborator}", "comments_url": "https://api.github.com/repos/rhysd/hello-github-actions/comments{/number}", "commits_url": "https://api.github.com/repos/rhysd/hello-github-actions/commits{/sha}", "compare_url": "https://api.github.com/repos/rhysd/hello-github-actions/compare/{base}...{head}", "contents_url": "https://api.github.com/repos/rhysd/hello-github-actions/contents/{+path}", "contributors_url": "https://api.github.com/repos/rhysd/hello-github-actions/contributors", "created_at": 1544355055, "default_branch": "master", "deployments_url": "https://api.github.com/repos/rhysd/hello-github-actions/deployments", "description": null, "downloads_url": "https://api.github.com/repos/rhysd/hello-github-actions/downloads", "events_url": "https://api.github.com/repos/rhysd/hello-github-actions/events", "fork": false, "forks": 0, "forks_count": 0, "forks_url": "https://api.github.com/repos/rhysd/hello-github-actions/forks", "full_name": "rhysd/hello-github-actions", "git_commits_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/commits{/sha}", "git_refs_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/refs{/sha}", "git_tags_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/tags{/sha}", "git_url": "git://github.com/rhysd/hello-github-actions.git", "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true, "has_wiki": true, "homepage": null, "hooks_url": "https://api.github.com/repos/rhysd/hello-github-actions/hooks", "html_url": "https://github.com/rhysd/hello-github-actions", "id": 161032314, "issue_comment_url": "https://api.github.com/repos/rhysd/hello-github-actions/issues/comments{/number}", "issue_events_url": "https://api.github.com/repos/rhysd/hello-github-actions/issues/events{/number}", "issues_url": "https://api.github.com/repos/rhysd/hello-github-actions/issues{/number}", "keys_url": "https://api.github.com/repos/rhysd/hello-github-actions/keys{/key_id}", "labels_url": "https://api.github.com/repos/rhysd/hello-github-actions/labels{/name}", "language": "Dockerfile", "languages_url": "https://api.github.com/repos/rhysd/hello-github-actions/languages", "license": null, "master_branch": "master", "merges_url": "https://api.github.com/repos/rhysd/hello-github-actions/merges", "milestones_url": "https://api.github.com/repos/rhysd/hello-github-actions/milestones{/number}", "mirror_url": null, "name": "hello-github-actions", "node_id": "MDEwOlJlcG9zaXRvcnkxNjEwMzIzMTQ=", "notifications_url": "https://api.github.com/repos/rhysd/hello-github-actions/notifications{?since,all,participating}", "open_issues": 0, "open_issues_count": 0, "owner": { "avatar_url": "https://avatars3.githubusercontent.com/u/823277?v=4", "email": "rhysd@users.noreply.github.com", "events_url": "https://api.github.com/users/rhysd/events{/privacy}", "followers_url": "https://api.github.com/users/rhysd/followers", "following_url": "https://api.github.com/users/rhysd/following{/other_user}", "gists_url": "https://api.github.com/users/rhysd/gists{/gist_id}", "gravatar_id": "", "html_url": "https://github.com/rhysd", "id": 823277, "login": "rhysd", "name": "rhysd", "node_id": "MDQ6VXNlcjgyMzI3Nw==", "organizations_url": "https://api.github.com/users/rhysd/orgs", "received_events_url": "https://api.github.com/users/rhysd/received_events", "repos_url": "https://api.github.com/users/rhysd/repos", "site_admin": false, "starred_url": "https://api.github.com/users/rhysd/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/rhysd/subscriptions", "type": "User", "url": "https://api.github.com/users/rhysd" }, "private": true, "pulls_url": "https://api.github.com/repos/rhysd/hello-github-actions/pulls{/number}", "pushed_at": 1544359187, "releases_url": "https://api.github.com/repos/rhysd/hello-github-actions/releases{/id}", "size": 3, "ssh_url": "git@github.com:rhysd/hello-github-actions.git", "stargazers": 0, "stargazers_count": 0, "stargazers_url": "https://api.github.com/repos/rhysd/hello-github-actions/stargazers", "statuses_url": "https://api.github.com/repos/rhysd/hello-github-actions/statuses/{sha}", "subscribers_url": "https://api.github.com/repos/rhysd/hello-github-actions/subscribers", "subscription_url": "https://api.github.com/repos/rhysd/hello-github-actions/subscription", "svn_url": "https://github.com/rhysd/hello-github-actions", "tags_url": "https://api.github.com/repos/rhysd/hello-github-actions/tags", "teams_url": "https://api.github.com/repos/rhysd/hello-github-actions/teams", "trees_url": "https://api.github.com/repos/rhysd/hello-github-actions/git/trees{/sha}", "updated_at": "2018-12-09T12:34:31Z", "url": "https://github.com/rhysd/hello-github-actions", "watchers": 0, "watchers_count": 0 }, "sender": { "avatar_url": "https://avatars3.githubusercontent.com/u/823277?v=4", "events_url": "https://api.github.com/users/rhysd/events{/privacy}", "followers_url": "https://api.github.com/users/rhysd/followers", "following_url": "https://api.github.com/users/rhysd/following{/other_user}", "gists_url": "https://api.github.com/users/rhysd/gists{/gist_id}", "gravatar_id": "", "html_url": "https://github.com/rhysd", "id": 823277, "login": "rhysd", "node_id": "MDQ6VXNlcjgyMzI3Nw==", "organizations_url": "https://api.github.com/users/rhysd/orgs", "received_events_url": "https://api.github.com/users/rhysd/received_events", "repos_url": "https://api.github.com/users/rhysd/repos", "site_admin": false, "starred_url": "https://api.github.com/users/rhysd/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/rhysd/subscriptions", "type": "User", "url": "https://api.github.com/users/rhysd" } }
アクション間で情報を受け渡しする
各アクションで毎回リポジトリが clone されるわけではなく,共通のワークスペースが使われます.なので前段のアクションで作成したファイルは後段のアクションでもアクセスすることができます.これによって,あるアクションでビルドした生成物を使って,後段で linter やテスト,デプロイを走らせるといったことができそうです.
サードパーティのアクションが後段にいる場合にはアクセストークンなどの秘密情報をファイルに保存しないように気をつける必要があります.試した限りでは環境変数は後段のアクションに受け継がれないので,前述の secrets 機能で環境変数に置いておくのが良さそうです.
Custom GitHub Action をつくる
アクションは別リポジトリや Docker コンテナに切り出して再利用することができます.actions organization に置かれている公式のアクション集が参考になります.
アクション1つのみを公開するとき
つくるリポジトリに対して公開するアクションが1つのときはリポジトリのルートにそのまま Dockerfile
と entrypoint.sh
を置きます
your-awesome-action/ ├── Dockerfile └── entrypoint.sh
使う側のリポジトリの .github/*.workflow
には uses
に owner/repo@ref
を指定すると使えるようになります.ref
はブランチ名か commit SHA1 の初め7桁を指定します(タグ名でも良い?).uses
は他にも docker://
で始めて直接 Docker コンテナを指定することもできるようです.
action "Awesome Action" { uses = "your-name/your-awesome-action@master" } workflow "hello" { on = "push" resolves = ["Awesome Action"] }
例: https://github.com/actions/npm
アクションを複数公開する時
1つのリポジトリで複数のアクションを公開したい時は,Dockerfile
と entrypoint.sh
をサブディレクトリに置きます.
your-awesome-action/ ├── action-a │ ├── Dockerfile │ └── entrypoint.sh └── action-b ├── Dockerfile └── entrypoint.sh
使う側のリポジトリの .github/*.workflow
には uses
に owner/repo/subdir@ref
を指定すると使えるようになります(ref
はアクション1つのみの場合と同じ).
action "Awesome Action A" { uses = "your-name/your-awesome-action/action-a@master" } workflow "hello" { on = "push" resolves = ["Awesome Action A"] }
例: https://github.com/actions/bin
感想
ざっと見た感じ,GitHub Action は下記の場合に便利そうです
- 開発のワークフローを自動化したいとき
- 自動でラベルを貼り替える
- 自動で issue を close する
- パッケージングしてデプロイしたりライブラリをリリースしたり
- ちょっとした CI を回したい
ただし下記の点は気をつけておいたほうが良さそうです
Rust プロジェクトで自動で Git hook をセットできる cargo-husky をつくった
npm でよく使っている husky というツールがあります.これは npm install
などを hook して Git hook を自動でセットしてくれるツールで,リモートにプッシュする前のチェックを強制してくれます.もちろん CI でもテストを回しているのですが,プッシュ前にもチェックすることによりケアレスミスに早く気付くことができます.
Rust でもこの仕組みを使いたかったのですが,そういうツールが無かった&つくれそうだったので cargo-husky というツールをつくりました.
基本的な使い方
cargo-husky
パッケージを Cargo.toml
の dev-dependencies
に追加して cargo test
を実行するだけです.
[dev-dependencies] cargo-husky = "1"
$ cargo test
cargo
はそのパッケージが必要になった段階でパッケージをダウンロードします. dev-dependencies なので cargo build
ではなく cargo test
を実行する必要があります.大抵開発中にテストを一度は実行すると思うので,意識して cargo test
を呼ぶ必要はなく,知らぬ間に Git hook がセットされているという意図です.
.git/hooks/
を見ると,こんな感じの pre-push
スクリプトが置かれているはずです.
#!/bin/sh # # This hook was set by cargo-husky v1.0.0: https://github.com/rhysd/cargo-husky#readme # Generated by script /path/to/cargo-husky/build.rs # Output at /path/to/target/debug/build/cargo-husky-xxxxxx/out # set -e echo '+cargo test' cargo test
これによって git push
前に cargo test
が自動で実行されるようになります.
フックをカスタマイズしたいとき
デフォルトでは pre-push
で cargo test
を実行するフックが置かれますが,cargo-husky
パッケージの feature flag を使ってこの挙動を変えることができます.
cargo book にもある通り,feature flag を指定するには [dev-dependencies.cargo-husky]
というセクションをつくります.
[dev-dependencies.cargo-husky] version = "1" default-features = false # デフォルトの挙動を無効化する features = ["precommit-hook", "run-cargo-test", "run-cargo-clippy"]
features
の配列に指定している値が有効にする機能です.この例では「コミット前に実行する(precommit-hook
)」,「cargo test
を実行する(run-cargo-test
)」,「cargo clippy
を実行する(run-cargo-clippy
)」機能を有効にすることにより,毎コミット前に cargo test
と cargo clippy
を実行する git hook pre-commit
が生成されます.
利用可能な feature flag は下記の通りです.
feature flag | 意味 | デフォルト値 |
---|---|---|
prepush-hook |
pre-push hook を生成 |
有効 |
precommit-hook |
pre-commit hook を生成 |
無効 |
postmerge-hook |
post-merge hook を生成 |
無効 |
run-cargo-test |
hook で cargo test を実行 |
有効 |
run-cargo-clippy |
hook で cargo clippy を実行 |
無効 |
user-hooks |
次の章を参照 | 無効 |
すでに生成された hook がある場合は,一旦 .git/hooks/
以下を削除してから cargo test
を実行してパッケージを再コンパイルしてください.
さらにフックをカスタマイズしたいとき
feature flag は固定値しか記述できないため,「自前で用意したスクリプトを実行したい」「cargo
に特定のオプションを渡して実行したい」といったカスタマイズはできません.
さらなるカスタマイズを可能にするために,user-hooks
という feature flag が用意されています.
[dev-dependencies.cargo-husky] version = "1" default-features = false features = ["user-hooks"]
この flag が有効になっていると,cargo-husky は自前で git hook を生成せず,リポジトリ直下に置かれている .cargo-husky
というディレクトリを探し,その中のスクリプトを代わりに .git/hooks
に配置します.
your-repository/ ├── .git └── .cargo-husky └── hooks ├── post-merge └── pre-commit
例えばディレクトリ構成がこのようになっているとき, pre-commit
および post-merge
が .git/hooks
以下に置かれる対象になります.
cargo-husky は .git/hooks
にスクリプトを置く際,ファイルの先頭にメタ情報(cargo-husky のバージョンなど)をヘッダとして挿入します.
その際,#
始まりを行コメントと想定しているため,それに準じた言語でスクリプトを書く必要があります.また,実行可能属性がついていないファイルはスクリプトとして認識せず無視します.なのでスクリプトには実行可能属性を付けておいてください(chmod +x
).これは意図しないファイルが .git/hooks
に置かれてしまわないようにするためです.
実装
husky は npm の install
フックなどで Git hook をセットしますが,cargo にはそういった仕組みはありません.
代わりに cargo の build script 機能を濫用することで cargo-husky は実装されています. 本来は外部ライブラリのビルドなどを設定するためのbuild.rs
内でフックをセットする処理を行っています.
cargo がビルド時に自動でセットする $OUT_DIR
に設定されたディレクトリを元にプロジェクトの .git
ディレクトリを特定するので,万一 $OUT_DIR
がリポジトリの外になっているような特殊なケースでは動きません.
cargo-husky は Linux/macOS/Windows で stable チャンネルのツールチェーンを使ってテストされており(Linux と macOS は Travis CI,Windows は Appveyor),MIT ライセンスで配布されています.