バイナリエディタ bed が homebrew/core に入りました

数年前に、趣味でbedというバイナリエディタを作ったことがありました。 当時はメモリーに乗らないような大きなファイルを描画したり、編集できるようにするロジックを考えるのが楽しく、とても熱中していました。 itchyny.hatenablog.com 基本的な機能をある程度作ってからは急速にやる気がなくなってしまい、細々とリファクタリングやパフォーマンス改善などをしてメンテナンスをしていました。

bedは元々itchyny/homebrew-tapでHomebrew formulaを配信していたのですが、最近homebrew/coreに入りました。 github.com これで、次のコマンドでbedをインストールできるようになりました。

brew install bed

私の作ったものでhomebrew/coreに入ったプロダクトが二つになりました。 一つ目は、もちろんgojqです。 誰かが自分のプロダクトを良いものだと思ってくれて、より多くの人に使われるように動いてくれるのは嬉しいことですね。

VimのYAMLのシンタックスハイライトを改善してGitHub Actionsのワークフローファイルでハイライトが壊れにくくしました

GitHub ActionsのワークフローにはYAMLファイルを使いますが、Vimシンタックスハイライトがうまく効かなくて困ることがよくありました。 Actionsでは複数行にわたる文字列に複雑なシェルスクリプトを書くことが多いのですが、 その中の一部がYAMLのフロースカラースタイルの文字列として認識されてしまい、ハイライトが壊れることがあるのです。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "Hello world!" | cut -f1 -d" "
          echo "This line is broken!"
      - env:
          test: ${{ inputs.* | join(' ') }}
        run: |
          echo 'This line is broken!'

はてなブログも全く同じ問題があることから、中ではVimシンタックスハイライトが使われているようです。 私の修正がブログのシステムに取り込まれると、このエントリーも更新できなくなりますが…

参考: YAMLの用語

参考: YAMLの用語
ブロックスカラースタイル: |
  これはブロックスカラースタイルの
  文字列です (改行を維持しない > もあるよ)
フロースカラースタイル:
  "これはダブルクォートされた
  フロースカラースタイルの
  文字列です (シングルクォートもあるよ)"
プレインスタイル: これは、
  クォートのないフロースカラースタイル、
  つまりプレインスタイルの文字列です

ブロックスカラースタイルのコードの中のシェルスクリプトとしての文字列に色がつくのは便利な気もしますが、YAMLのフロースカラースタイルの文字列は改行も含めるなど自由度が高いので、ブロックスタイルの文字列の中でフロースタイルの解釈をするのはおかしいですね。 また、プレインスタイル文字列の中のシングルクォートの解釈もおかしいです (上のjoinの例)。

GitHub Actionsをよく触るようになってからYAMLシンタックスハイライトが壊れることが多くなったので、重い腰を上げてVimYAMLシンタックスハイライトを改善しました (色々とあって取り込まれてからブログを書くまで時間がかかってしまいました…)。

github.com

このパッチの中でも、特に重要なのは以下の部分です。

syn match yamlBlockScalarHeader '[|>]\%([1-9][+-]\|[+-]\?[1-9]\?\)\%(\s\+#.*\)\?$' contained
            \ contains=yamlComment nextgroup=yamlBlockString skipnl
syn region yamlBlockString start=/^\z(\s\+\)/ skip=/^$/ end=/^\%(\z1\)\@!/ contained

|->1-といったブロックスカラースタイルのヘッダーを正しく認識し、その後に続くブロックスカラースタイルの文字列を認識するようになりました。 これによって、文字列の中に複雑なシェルスクリプトを書いてもYAMLとしてのハイライトが壊れにくくなりました。

Vimシンタックス定義の詳しいドキュメントはこちらです。 ここでは軽くシンタックスを作る時の考え方を書いておこうと思います。 Vimシンタックス定義の二つの大事な考え方は、nextgroupによる状態遷移と、containsによる構文アイテムの入れ子構造です。

シンタックスハイライトの構文アイテムには、トップレベルのアイテムとそうではないものがあります。 まずはトップレベルの構文アイテムでもって、そのパターンがあればファイルのどこでも認識したいものを定義し、そこからnextgroupを使って次に続くアイテムを指定していくというのが基本的な考え方です。 例えば、YAMLだと行が /^\s*\zs-\ze\%(\s\|$\)/ にマッチした場合は基本的にファイルのどこでもリストのマーカーとして認識させたいので、トップレベルのアイテムとして定義します。 トップレベルのアイテムがあることで、ファイルの途中からでも構文を認識できるようになっています。

多くのプログラミング言語(PietやBefungeといった例外を除く)の文法は、木構造で表現できます。 つまり構文要素の隣接関係と包含関係によって表現できるということです。 これらに対応するのが、nextgroupcontainsです。

隣接関係とは、functionというキーワードの後には識別子が続き、さらに開き括弧、引数、閉じ括弧、ブロックの開始が続くといった感じです。 Vimシンタックス定義では、nextgroupというオプションで次に続く構文アイテムを指定することができます。 ここにはトップレベルではないアイテム、containedというオプションを指定したアイテムを指定します。 実際に書いていくと同じアイテムたちを何度も指定したくなりますが、そのような場合はsyntax clusterを使うと便利です。

トップレベルのアイテムとnextgroupを使えばある程度の文法は表現できるのですが、これだけでは表現できない文法もあります。 例えば、"1 ${2 + 3} 4"みたいな文字列補間は、隣接関係だけでは表現できません。 他にも、文字列の中の\uXXXXのようなエスケープシーケンスとか、コメントの中のTODOとか、そういうものに色をつけたいことがあります。 こういった包含関係を表現するために、containsというオプションで構文アイテムの入れ子構造を表現します。 状態遷移と入れ子構造を組み合わせることで、Vimシンタックス定義は複雑な構文に対応しているのです。

今回、YAMLシンタックスハイライトを改善したことで、Vimシンタックス定義の仕組みを改めて自分の中で整理できました。 Vimシンタックスを改善したい人に、この記事が参考になれば幸いです。 個人的には、よく編集するGitHub Actionsのワークフローファイルでのハイライトを改善できましたし、積年のいくつかの問題 (#8234#10730#11517) を一気に解決できたので、とても満足しています。 それでは、また。

GitHub ActionsでファイルをS3にキャッシュするアクションを作りました

GitHub Actionsでは依存パッケージやビルド結果などをうまくキャッシュすることで、テストやビルドの時間を短縮できます。 actions/setup-nodeactions/setup-javaなどの各言語のオフィシャルアクションは各パッケージマネージャーのためのキャッシュ機構を提供していますし、actions/cacheを使って任意のファイルをキャッシュすることもできます。 これらは内部で@actions/cacheパッケージを使っており、キャッシュの機構はGitHub自身の機能と密に結びついています。 しかし、GitHub Actionsのキャッシュはリポジトリごとに10GBまでという制限があり、開発者の多いリポジトリではsetup-nodeのキャッシュだけでもすぐに上限に達してしまいます。 私の所属するチームのリポジトリGitHub Enterprise Serverにホストされており、キャッシュの制限は25GBに緩和してもらっていますが (参考)、それでも一日に数十GB以上利用してしまう日もあり、効果的にキャッシュを利用できているとは言えません。

今回、GitHub ActionsでファイルをAmazon S3にキャッシュするアクションをフルスクラッチで作りました。 二週間前から作り始めてようやく形になってきたので、タグを打ってMarketplaceにも公開しました。

- uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-region: ${{ vars.S3_CACHE_AWS_REGION }}
    role-to-assume: ${{ vars.S3_CACHE_ASSUME_ROLE_ARN }}
- uses: itchyny/s3-cache-action@v1
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
    bucket-name: ${{ vars.S3_CACHE_BUCKET_NAME }}
    # AWSの認証情報を直に指定することも可能
    # aws-region: ${{ vars.S3_CACHE_AWS_REGION }}
    # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

基本的にactions/cacheとほぼ同じように使えますが、いくつかの違いがあります。 まず、ブランチによるスコープ分離がありません。 actions/cacheのこの制約 (参考) は安全だとは思いますが、私のチームでは同じキーのキャッシュが大量に作られることもあり、とても不便に感じています。 私のアクションにはブランチによるスコープは実装していません。 必要であればkeysrestore-keysにブランチ名を含めると良いでしょう。 また、actions/cacheWindowsとそれ以外のOSでキャッシュが混ざらないようになっていますが、同じことはkeyrunner.osを含めることで実現できるので、私のアクションでは実装していません。 そのためenableCrossOsArchiveというオプションはありません。

actions/cacheにはブランチのスコープとは別にバージョンという概念があります (参考)。 簡単に言うと、キャッシュのpathが異なるキャッシュは別のバージョンとして扱われます。 これは重要な機能で、単純にkeyだけでキャッシュをマッチさせてしまうとpathだけを変えた時に意図しないキャッシュをリストアしてしまいます。 キャッシュのバージョンがあるおかげで、pathに新しいディレクトリを追加したとしても新しいkeyを考えなくてもよくなっているのです。 私の作ったアクションでも、pathに基づいたハッシュをオブジェクトのキーに付与することで同じような挙動を実装しています。

actions/cachepathが違えば別のキャッシュとして扱われるというこの挙動は、実装を追っていくと納得できる挙動でもあります。 このアクションは、tarコマンドで--absolute-names (-P)オプションを使って絶対パスを含めたアーカイブにして保存しています。 展開時も同じオプションで展開するだけで、例えばpathに相当する場所に移動するという処理はありません。 そのため、仮にpathが一つ指定されているだけであったとしても、パスが違えば別のキャッシュとして扱われるのです。 actions/cache/saveで保存したファイルを別のパスにactions/cache/restoreできないのも、この実装によるものです。

今回、アクションを実装する前に既存のアクションが使えるかをかなり調査しましたが、自分のユースケースでまともに動きそうなアクションは一つも見つけられませんでした。 例えばS3にオブジェクトがたくさんある時にうまく動かなかったり、キャッシュのバージョンに相当する挙動を実装していなくてpathを変えてもリストアしてしまったりしました。 また、actions/cacheをforkしていたり、S3にアクセスできない場合にfallbackする機能を実装していたりして、私が欲しい物に対して実装が大きすぎて実装を追うのもつらく、メンテナンスも厳しそうに感じました (弊社ではVerified creatorでない作者のアクションを導入するにはソースコードの精査が義務付けられています)。 actions/cacheの中でも特に重要な機能を抽出しつつ、キャッシュをS3に保存するだけのシンプルなアクションが欲しかったので、自分で作ることにしました。

actions/cacheはネイティブのtarコマンドを実行していますが、s3-cache-actionnode-tarを使っています。 内部的にはnode-tarのtar部分はJavaScriptで書かれていて、gzipにはNode.jsのzlib bindingを使っています。 この実装で十分に速度が出ているので、特に問題はないと思っています。 Brotliも検証しましたが、npmパッケージの保存を試したところ圧縮処理がとても遅くなり、キャッシュサイズもgzipと大差なかったのでやめました。 globパターンの展開はactions/cacheと同じく@actions/globを使っているので、ここの挙動の際はありません。

GitHub Actionsでのキャッシュにお困りの方は、ぜひ使ってみてください。 それでは、また。 github.com

リリース時にのみ行う処理はできるかぎり減らす

GitHub Actionsなどでテストやリリースを自動化していると、様々なトリガーによって異なる処理を行うことはよくあることです。 例えばpushのトリガーではテストやlintを行い、tagをpushしたときはクロスビルドしてリリースする、などです。 これらの処理は内容も頻度も異なるので、ワークフローのファイルを分けることはよくあることだと思います。 私もかつてはこのようにトリガーや実行したい頻度が異なるのだから分けるのは自然なことだと考えていました。 一つのワークフローの中でトリガーによって処理を分岐させるとワークフローが複雑になるし、面倒なことが多いからです。

しかし、最近はリリース時の処理をテストと同じワークフローにまとめる方が良いと考えるようになりました。 リリースする時になって初めて動く処理が多いほど、リリースのワークフローが壊れるリスクが高まるからです。 GitHub Actionsになって、依存するactionの更新や実行するrunnerの更新など、ジョブが外的要因によって壊れることが増えたように感じます。 クロスビルドやDockerイメージのビルドなどリリースする時しか使っていないツールやactionがあると、それらの更新を取り込んだ後の最初のリリースで動かないことに初めて気が付くというリスクがあります。 ワークフローが分かれていると、リリースのコミットやその時の外的要因 (CIの不調とか依存の更新とか) によってテストが落ちるようになったのにリリースされてしまうという懸念もあります。

テストもリリースも一つのワークフローにまとめた上で、リリース時にのみ行う処理はできるかぎり減らすというのが良いと思います。 リリースかどうかの分岐をあちこちに書く羽目になったとしても、です。 クロスビルドは普段からビルドすれば良いですし、Dockerイメージのビルドも常に行っておくべきです。 リリース時には、そういうビルド済みのアーティファクトをダウンロードしてアップロードするだけとか、Dockerイメージをpushするだけとか (docker/build-push-actionならpushオプションでオンオフできる)、そういう処理に限ることが望ましいです。 要するに、リリースのためのビルド処理は、テストと同じ頻度で行いましょうということだと思います。 また、テストが通らなかったらリリースを止めたいという素朴な要件が、ワークフローがまとまっていれば簡単に達成できるのです (GitHub Actionsがワークフロー間の依存を定義しにくいという事情もあるでしょう。workflow_runってみなさん使ってます?)。

私はjqのメンテナをやっているのですが、jqのCIもテストとリリースを一つのワークフローにまとめています。 それでもなお、dependabotによるactions/upload-artifactactions/download-artifactの更新PRが別々に来た時に、他のメンテナが前者のみをマージしてしまい一時的にリリースジョブが壊れてしまったことがあります。 ビルドした実行ファイルをダウンロードしてDockerイメージを作るためにdownload-artifactを使っていたのですが、そのジョブはリリースの時にしか実行していませんでした。 今は、PRの作成時もDockerイメージのビルドを行うようにしています。 github.com

CIリソースを心配されるかもしれませんが、大体のケースではキャッシュを活用すれば節約できますし、リリースジョブが壊れにくくするための必要なコストかなと思っています。 もちろんEnvironmentsの都合などで常には実行できない処理は諦めざるを得ない場面もあるでしょう。 できるだけリリースする時のリスクを減らすための考え方の一つとしてご紹介しました。 他の手としては、リリース用のワークフローは手動でdry runできるようにしておく方法もありそうです (経験上、どんなワークフローでもworkflow_dispatchをつけておいて損はないと思います)。

2023年を振り返って

サイボウズに入社して二年が経ちました。 プロダクトのインフラ基盤の移行に携わりながら、チームの生産性を向上させる様々な取り組みを行っていました。 特にCircleCIからGitHub Actionsへの移行はかなりコミットしました。 色々な知見が溜まったのですがアウトプットし損ねています。 来年は失ったオープンネスを取り戻してチームの取り組みを外に出していきたいです。

OSS活動としてはjqのメンテナになったのが大きい変化です。 jqのリポジトリをjqlang orgに移譲して、新しいメンテナの体制の元で、新しい1.7というバージョンをリリースできました。 itchyny.hatenablog.com itchyny.hatenablog.com 様々な機能を実装してリリースできたことはもちろん、持続的な開発体制に移行できたのはなによりも大きな変化でした。 これからもjqの開発に貢献していきたいです。

jqのリリースが落ち着いた秋頃、Rustへの興味が再燃しました。 長らく放置していたMackerelのREST APIのRustクライアントのメンテナンスを再開し、Mackerelの機能追加に追従しつつ、APIの使い勝手を大幅に改善しました。 この改善の内容は、アドベントカレンダーの記事として公開しました。 itchyny.hatenablog.com さらに、この改善が落ち着いた頃に公式のGoクライアントの実装のリファクタリングも行いました。 この内容も、アドベントカレンダーの空き枠があったところにシュッと入って公開しました。 itchyny.hatenablog.com

今年の前半は、とにかく結婚式の準備に奔走していていました。 テーブルクロスの色について永遠に議論したり、印刷所に行って席次表を印刷したり、音声のトラブルがあり焼き直したDVDを持って休日の郵便局に駆け込んだりしたのが、はるか昔の出来事のようです。 式の当日はあっという間に過ぎてしまいました。 雰囲気の良い式場で挙式できて本当に一生の思い出になりました。 五月に旅行で行った北海道もとてもよかったです。

生成系AIがあっという間に広まった一年でした。 個人的にはChatGPTよりもGitHub Copilotの方が衝撃が大きかったです。 ChatGPTは確かに便利ではあるものの、たまに手紙やスピーチの原稿を作ってもらう程度で、そこまで生活を変えた感覚はありません。 しかし、GitHub Copilotは違いました。 コーディングの速度が圧倒的に上がり、もはや手放せないツールになっています。 特に、類似するコードが同じファイルにあるときにCopilotが提案するコードは驚くほど精度が良く、数秒で狙った実装やテストコードが完成することも少なくありません。 今まで画面を分割して似たようなコードを参考にしながら頑張って書いていたのはなんだったんだろうと思います。

今年はよくドラマを見ていました。 TBS系日曜劇場の『VIVANT』は圧倒的に良かったですね。続編が見たいです。 『星降る夜に』は雰囲気が好みで毎話二回は見ていました。 『いちばんすきな花』も大好きで何度も見返していました。 藤井風の主題歌がドラマの雰囲気とよく合ってました。 他にも『ハヤブサ消防団』『罠の戦争』『トリリオンゲーム』『ブラッシュアップライフ』が記憶に残っています。 アニメは『葬送のフリーレン』を見ていました。

今年は結婚式や住環境の変化もあり慌ただしい一年でした。 来年は落ち着いて新しいことに挑戦する年にしたいです。

志木美鳥「他人の価値観なんて理解できないけど、理解したいと思える他人と出会えることはある」

いちばんすきな花 第11話