dependabotのPRに自動でビルド成果物をコミットする

ビルド成果物をリポジトリに含める必要がある場合、どのタイミングでコミットするかが問題になります。 例えばGitHub Actionsのように、JavaScriptの成果物をリポジトリに含める必要があるようなケースです。 リリースを打つ時にビルド成果物をコミットするという方法もありますが、この記事ではメインブランチへのマージ時にはビルドしないといけないという前提があることにします。

人間がPRを出す場合は手元からビルドして成果物も一緒にコミットできますが、dependabotのようなbotにはビルドをさせることができません。 しかし、GitHub Actionsを使えば、dependabotのPRに対してもビルド成果物をコミットすることができます。

name: CI

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha || github.sha }}
          token: ${{ secrets.DEPENDABOT_TOKEN || secrets.GITHUB_TOKEN }}
          fetch-depth: ${{ github.actor == 'dependabot[bot]' && 2 || 1 }}
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - name: Install Dependencies
        run: npm ci
      - name: Run tests
        run: npm run test
      - name: Build
        run: npm run build
      - name: Check for dist changes
        id: dist-changes
        run: git diff --exit-code
      - name: Push dist changes for dependabot
        if: github.actor == 'dependabot[bot]' && failure() &&
          steps.dist-changes.outcome == 'failure'
        run: |
          git config user.name "$(git show -s --format=%an)"
          git config user.email "$(git show -s --format=%ae)"
          git commit --all --amend --no-edit
          git push --force origin "HEAD:${GITHUB_HEAD_REF}"
  • GITHUB_TOKENの権限を強めたくないので、permissionsは読み取り権限のみにしています。Dependabot secretを使うことで、dependabotのPRでしか利用できないシークレットを設定できます。このシークレットには該当リポジトリへのwrite権限を付与したアクセストークンを設定しておきます。
  • pull_requestイベントでactions/checkoutを使うと、マージコミットをチェックアウトしてしまいます。今回はPRにコミットを積みたいので、refにgithub.event.pull_request.head.shaとpushイベント時のフォールバックを指定します。
  • あとはテストとビルドを行います。そしてビルド成果物が変更されているかを確認し、もし変更があればdependabotのPRにコミットします。今回はパッケージの更新コミットにamendしたかったので、push --forceで更新しています。また、fetch-depthã‚‚2にしています。もしamendしない場合はpush --forceも不要でfetch-depthを指定しないでも問題ありません。

ポイントはチェックアウトするときのrefと、Dependabot secretを使うことでしょうか。 GitHub Actionsがそうなのですが、ビルド成果物をコミットしなければならないというシチュエーションはそもそも珍しいかもしれません。 しかし、Dependabot secretの存在を知っておくと、dependabotのPRにのみ利用できるシークレットを設定できるのでとても便利ですね。

それではまた!

バイナリエディタ bed のコマンドラインの機能を強化しました

先日、自分の作っているバイナリエディタbedがhomebrew/coreに入ったことをご報告しました。

その後、コードを眺めていると色々と直したいところが出てきたり、欲しい機能の実装イメージが沸いたりして、また活発に開発するようになりました。 今でもそれなりに使われていることを意識すると、急にメンテする気力が湧いてくるんですよね。不思議です。 個人OSSの継続にはモチベーションが大事です。フィードバックは大歓迎です。

最近、コマンドライン周りの機能強化をやっています。

  • コマンドの実行履歴を上下キー (と<C-n>, <C-p>) で辿れるようにしました。実装としては、実行したコマンドをスライスに追加していく (のと重複を削除する) だけです。少し面倒なのが、コマンド実行 (:) と検索 (/, ?) で別の履歴として保存しないといけないことです。
  • コマンドラインで環境変数の補完に対応しました。例えば、$GOPATHのように環境変数がディレクトリを指しているとき、 :e $GOP<TAB> で :e $GOPATH/ まで補完して、さらにその中のファイルを候補として表示してくれます。
  • 新しいコマンドとして :cd と :pwd を実装しました。:cdは作業中のディレクトリを変更するコマンドで、:pwdはそれを表示するコマンドです。:cd - で前にいた場所に戻るといった細かい (けど使う人には便利な) 機能も実装しています。

全く別の文脈 (とあるVimプラグイン) で作業ディレクトリのことを考えていて、bedでもVimみたいにディレクトリを移動できればファイルを開くのが楽になるなと思い:cdの実装を考え始めたのがきっかけでした。 初めはos.Chdirを呼ぶだけだと思っていたのですが、これまでコマンド実行中にディレクトリが移動することがなかったので、既存の機能に影響して苦労しました。 例えば :w test で保存したウィンドウは、その後 :w で同じファイルを上書きします。 しかし、このtestが作業中のディレクトリからの相対パスになっていたので、:cdを作ったことで挙動が変わってしまいました。 各ウィンドウで絶対パスを管理することで、この問題は解決しました。

コードを読み返していると、当時勢いで書いた部分が多く、色々な箇所をリファクタリングしたくなってきました。 今回は、コマンドラインのパーサーを大きく書き直しました。 昔は[]runeを引き回して次のオフセットを返すようなパーサーをよく書いていたのですが、Goのプラクティス的には文字列を渡してパースしたものと残りの文字列を返す方が良いということがわかってきました。 strings.CutPrefixはとても便利ですね。 他にもやたら状態遷移するコードを好んで書いていたのですが、よほど複雑なものでない限りはコードが読みにくくなるだけなのでやめました。 ここ数年でもだいぶコーディングの好みが変わってきているのを感じます。

テストもだいぶ書き直して、読みやすく安定したものになりました。 bedのテストを見返すと、UIが送ったイベントをウィンドウが処理するのを待つためにsleepするみたいなテストが存在していました。 例えば、UIが:wと:qのイベントを送った時に、ファイル保存が完了するよりも前に:qを処理してしまうと、保存がうまくいきません。 でもテストの中でsleepするのは良くないですよね。何を待っているのかわからなくなりますし、その時間で処理が完了する保証もなく、不安定になってしまいます。 UIが送ったイベントに対しては再描画のリクエストがUIに返ってくるので、そのイベントの受信を待つよう修正することでsleepを撲滅することができました。 テストで何かを待ちたくなったら、sleepするより良い待ち方はないのか、本当にsleepする時間内に完了する保証があるのか考える必要があります。

Vimの操作に慣れていて、バイナリファイルを軽く確認したい、軽く編集もしたいという人に、bedはとてもオススメできるツールです。 ぜひ使ってみてください。

バイナリエディタ 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のシンタックスハイライトが壊れることが多くなったので、重い腰を上げてVimのYAMLのシンタックスハイライトを改善しました (色々とあって取り込まれてからブログを書くまで時間がかかってしまいました…)。

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といった例外を除く)の文法は、木構造で表現できます。 つまり構文要素の隣接関係と包含関係によって表現できるということです。 これらに対応するのが、nextgroupとcontainsです。

隣接関係とは、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-nodeやactions/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のこの制約 (参考) は安全だとは思いますが、私のチームでは同じキーのキャッシュが大量に作られることもあり、とても不便に感じています。 私のアクションにはブランチによるスコープは実装していません。 必要であればkeysやrestore-keysにブランチ名を含めると良いでしょう。 また、actions/cacheはWindowsとそれ以外のOSでキャッシュが混ざらないようになっていますが、同じことはkeyにrunner.osを含めることで実現できるので、私のアクションでは実装していません。 そのためenableCrossOsArchiveというオプションはありません。

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

actions/cacheのpathが違えば別のキャッシュとして扱われるというこの挙動は、実装を追っていくと納得できる挙動でもあります。 このアクションは、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-actionはnode-tarを使っています。 内部的にはnode-tarのtar部分はJavaScriptで書かれていて、gzipにはNode.jsのzlib bindingを使っています。 この実装で十分に速度が出ているので、特に問題はないと思っています。 Brotliも検証しましたが、npmパッケージの保存を試したところ圧縮処理がとても遅くなり、キャッシュサイズもgzipと大差なかったのでやめました。 globパターンの展開はactions/cacheと同じく@actions/globを使っているので、ここの挙動の際はありません。

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