詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

GitHub ActionsでRustプロジェクトをクロスビルドしてリリースする

前のエントリではバイナリを GitHub のリリースとして配布しているけれど、これは GitHub Actions でビルドとリリースのアップロードを行っている。いろいろ試行錯誤した結果なのでメモを残しておく。

ビルドに成功してからリリースを作成する、とした違いはあるけど、基本的に以下のエントリを参考にしている。

【GitHub Actions】Go言語の自動テストからリリースまでを作ってみた - Qiita

できあがりの workflow はこういう感じ。

https://github.com/motemen/slack-stream-json/blob/97d3745dcc8931a1d75217573d5ca60705be632f/.github/workflows/release.yml

一度 CI をセットアップしてしまうと、そのあと触ることは全然ないので、時間をあけて完全に忘れてしまった状態でも、問題なくメンテできるようになっていてほしい。そういう観点では、公式っぽいアクションだけで構成できたのはよかった。

ジョブはこういう流れになっている。

  • Build (matrix)
  • Create-release
  • Upload-release (matrix)

Build

  build:
    strategy:
      matrix:
        target:
          - x86_64-unknown-linux-gnu
          - x86_64-pc-windows-gnu
          - x86_64-apple-darwin
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
          - target: x86_64-pc-windows-gnu
            os: ubuntu-latest
          - target: x86_64-apple-darwin
            os: macos-latest

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v1

      # https://github.com/actions/cache/blob/master/examples.md#rust---cargo
      - name: Cache cargo registry
        uses: actions/cache@v1
        with:
          path: ~/.cargo/registry
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
      - name: Cache cargo index
        uses: actions/cache@v1
        with:
          path: ~/.cargo/git
          key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
      - name: Cache cargo build
        uses: actions/cache@v1
        with:
          path: target
          key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}

      - uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true
      - uses: actions-rs/cargo@v1.0.1
        with:
          command: build
          args: --release --target=${{ matrix.target }}
          use-cross: true

      - run: |
          zip --junk-paths slack-stream-json-${{ matrix.target }} target/${{ matrix.target }}/release/slack-stream-json{,.exe}
      - uses: actions/upload-artifact@v1
        with:
          name: build-${{ matrix.target }}
          path: slack-stream-json-${{ matrix.target }}.zip

Linux、macOS、Windows それぞれでビルドしたいので、matrix を使う。なんか cross というのを使えば(actions-rs/cargo のオプションにある)1ホスト上で全部クロスビルドできそうだったんだけど、x86_64-apple-darwin 向けのビルドで "error: failed to run custom build command for backtrace-sys v0.1.32" などと言われてしまい簡単にはできなそうだった。このへんの仕組みは何も知らないので、OSごと切り替えられるのであればそちらのほうがシンプルだろうと、そのようにした。

x86_64-pc-windows-gnu 向けは Linux でビルドできた。ここが Windows でしかビルドできなかったら、zip コマンドを使い変えないといけなくて大変だったのではないだろうか……。

actions/cache も Rust/Cargo に対応している

ビルドしたバイナリは、あとでリリースに上げたいので actions/upload-artifact しておく。

Create-release

  create-release:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - id: create-release
        uses: actions/create-release@v1.0.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: Release ${{ github.ref }}
          draft: false
          prerelease: false
      - run: |
          echo '${{ steps.create-release.outputs.upload_url }}' > release_upload_url.txt
      - uses: actions/upload-artifact@v1
        with:
          name: create-release
          path: release_upload_url.txt

actions/create-release でタグに対応するリリースを作成する。リリースを作るのは一度しかできない(エラーになる。ここでちょっとハマった)ので、matrix から外して、build とは別のジョブとして立てる。

リリースのアップロード用 URL を、これも後で使うので upload-artifact しておく。

Upload-release

  upload-release:
    strategy:
      matrix:
        target:
          - x86_64-unknown-linux-gnu
          - x86_64-pc-windows-gnu
          - x86_64-apple-darwin
    needs: [create-release]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v1
        with:
          name: create-release
      - id: upload-url
        run: |
          echo "::set-output name=url::$(cat create-release/release_upload_url.txt)"
      - uses: actions/download-artifact@v1
        with:
          name: build-${{ matrix.target }}
      - uses: actions/upload-release-asset@v1.0.1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ steps.upload-url.outputs.url }}
          asset_path: ./build-${{ matrix.target }}/slack-stream-json-${{ matrix.target }}.zip
          asset_name: slack-stream-json-${{ matrix.target }}.zip
          asset_content_type: application/zip

これが最後のフェーズ。あまりそうする必要もないけど、build と合わせるため matrix を使う。actions/download-artifact で前の2つのジョブの結果を受け取って、actions/upload-release-asset する。これで各 OS 分のファイルをリリースに添付できる。

標準出力に ::set-output と送ることで、ステップの output を設定できるらしい。おおう。ステップ名には - も使えるので、今回はケバブケースを採用してみた。

ターミナルでSlackを読む

Slackはそのクライアントがそれなりに、かなりよくできていて、これでほとんど困ることはないんだけど、そうは言ってももうちょっとプログラマブルに取り扱いたいこともある。

そういう場合にもよいAPIが用意されていて、Real Time Messaging API ってのがある。こいつはWebSocketでSlackの発言をはじめ、あらゆるイベントのJSONを送りつけてくれるやつ。ひとまずこれを標準出力に流すことができれば、あとは好きに料理できるはずだ。

というわけで作ったのがこちら。書いたことなかったのでRustです。ちょうどいいネタだった。

GitHub - motemen/slack-stream-json

slack-stream-json というバイナリが、SLACK_TOKEN 環境変数を設定した上で起動してやると、RTM APIによって得られたイベントのJSONをそのまま標準出力に流してくれる、それだけのツール。あとは適当なツールやプログラムで欲しい情報を取り出せばよい。

コマンドラインオプション

というだけだとさすがに素朴すぎる(curl + jq + wscat と変わらない)ので、Slack用の便利オプションがいくつかある。

--inflate-fields

Slack RTM でやってくるイベントは、以下のような形状をしている。

{
  "type": "message",
  "channel": "C2147483705",
  "user": "U2147483697",
  "text": "Hello world",
  "ts": "1355517523.000005"
}

これをそのまま標準出力に出力されてもあまりうれしくない……。どんなユーザがどんなチャンネルに投稿したのか、直にはわからないからだ。そこでこの userchannel フィールドを、対応するオブジェクトに展開してやるオプション。

このオプションをつけると、上掲のJSONは以下のように出力される。

{
  "type": "message",
  "channel": {
    "id": "C2147483705",
    "name": "fun",
    "created": 1360782804,
    "creator": "U024BE7LH",
    ...
   },
   "user": {
     "id":"U2147483697",
     "name":"motemen",
     ...
   },
   "text": "Hello world",
   "ts": "1355517523.000005"
}

これは便利。ネストされたフィールドに関してはまだサポートしてないのと、途中から増えたチャンネルやユーザもまだ未対応。

--format-message

同様にSlackにおける発言も決まったフォーマットに則っていて、たとえばメンションは

{
  "text": "Hey <@U024BE7LH>, thanks for submitting your report.",
  ...
}

みたいな形でやってくる。こういうやつを解決して

{
  "text": "Hey @motemen, thanks for submitting your report.",
  ...
}

にしてくれる。テキストにするので、リンクのURLとかは失われます。

<https://www.example.com/|example> my site <https://www.example.com/>

example my site https://www.example.com/

になる感じ。:emoji: の展開はしません。emojifyを使うと絵文字も展開できるが、emojifyはGitHubスタイルの:emoji:にしか対応しておらず、GitHubとSlackは微妙に違うので展開できないものもある(:thinking_face:とか)。 ​

--print-start-response

チャンネルやユーザの情報は、rtm.start APIを叩いた時に返ってくるJSONに含まれている。JSONのストリームをプログラムから利用したい場合は、この情報もあったほうが便利ですよねー。ということで最初のJSONとしてこのレスポンスを表示します。

jqと組み合わせてSlackを読む

そういうわけで、jq と組み合わせればこういう使い方ができます:

% SLACK_TOKEN=xxx slack-stream-json -f -i | jq --raw-output --unbuffered 'select(.type == "message" and (.text | length) and .user) | "\u001b[30m\(.ts | tonumber | localtime | strftime("%X")) \u001b[32m#\(.channel.name) \u001b[33m@\(.user.name)\u001b[m \(.text)"'

全部のチャンネルの投稿が流れてくるので、往年のIRCクライアントみたいなビューが実現できて感激。色までつけちゃってます。

f:id:motemen:20191121201058p:plain
ひとりで喋っている

fzfでインタラクティブにフィルタする

とはいえすべてのチャンネルの発言がフラットに流れてくるだけだと普通に読みづらい……どうしたものか、と考えて編み出したのがfzfと組み合わせてログをフィルタする方法! 上述のストリームを fzf に食わせれば、気になった発言を見たときにチャンネル名 #xxx とか発言者 @yyy でコンテキストを絞り込むことができる。これはやべー!

% SLACK_TOKEN=xxx slack-stream-json -f -i | jq --raw-output --unbuffered 'select(.type == "message" and (.text | length) and .user) | "\u001b[30m\(.ts | tonumber | localtime | strftime("%X")) \u001b[32m#\(.channel.name) \u001b[33m@\(.user.name)\u001b[m \(.text)"' | fzf --ansi --no-sort --tac --nth=2..

f:id:motemen:20191121202718g:plain

まあ、ただ、出力をどんどん溜め込むのでメモリを食っちゃうことは予想できる。つい最近出たfzf 0.19.0のreloadってのを使ったらマシになるのかもしれない。

ネットワークが切れたときとか、復帰はうまくいかないと思うのだけど、それは宿題ということにしておく。どうぞご利用ください。


スクショ撮るのに適切なチームがなかったのでオンラインサロンMOTEMENを使いました^^;

curl でデータを逐次アップロードするには -T (--upload-file) を使う

最近知ってへーとなったのでメモ。標準入力から curl を通じてサーバにデータを送信したい場合、

curl --data-binary @- <url>

というのがイディオムだと思うけど、この場合は標準入力から全部読みきってからサーバに接続・送信するので、データをストリーミングすることができない。コマンドライン経由のリアルタイムなコミュニケーションに HTTP を気軽に使いたい身としては、これは不便。

不便だなーと思って調べていたら、curl には -T (--upload-file) というオプションがあって、これは PUT でファイルを送信するのだけど、これは Transfer-Encoding: chunked しつつ、データを逐次読み取りながらサーバに送信するものらしい。

たとえば

# Linux なら nc -l -p 9999
nc -l 9999

しつつ、別のシェルで

curl -s -T - localhost:9999

すれば、毎行を入力するごとにサーバ側で受信できていることが確認できる。

とくに標準入力から入力を受けとる場合、-T - の代わりに -T . とすれば標準入力がノンブロッキングになって、入力を待たずにサーバからの応答を得ることができる(のだと思ってる。細かい挙動がよくわかってない)。

Use the file name "-" (a single dash) to use stdin instead of a given file. Alternately, the file name "." (a single period) may be specified instead of "-" to use stdin in non-blocking mode to allow reading server output while stdin is being uploaded.

はてなで一緒に働きませんか?