GitHub ActionsでPRタイトルをエスケープする方法と問題点
はじめに
こんにちは、ヨシです。
6 月に入り非常に暑くなってきましたね。自分はカフェでの作業などが好きなのですが、冷房や冷たいものをよく飲んだりしているせいか、体調を崩し気味です。皆さんも体調には気をつけてお過ごしください。
今回は業務で出会った GitHub Actions についてのつまずきポイントについての内容となります。
GitHub Actions とは
GitHub Actions とは、コードのビルドやテスト、デプロイなどのパイプラインを自動化することができる GitHub 上の CI/CD プラットフォームです。
より具体的にはリポジトリへのコードの push や、issue と PR の作成やラベル付けなど、GitHub 上での様々なアクションから自動化された一連のワークフローをトリガーすることができます。
具体的なユースケース
例えば、PR を作成したときに、まだレビューしてほしくない場合には wip
、PR が完成しレビュアーにレビューして欲しい状態となった場合には request review
といったラベルを付けるルールを採用しており、Slack に request review
のラベルが貼られたことを PR の内容と共に通知してほしいというようなケースがあるとします。
このようなケースの場合、GitHub Actions では、Slack がオフィシャルに提供する slack-github-action やサードパーティ制の action-slack などが利用できます。
そして、リポジトリの .github/workflows/
ディレクトリ以下にそのアクションを記述する YAML ファイルを作成します。上記の例を再現するためにシンプルなワークフローを記述してみます。
# ワークフローの名前
name: send-slack-notification
# このワークフローのトリガーとなる操作(今回の場合にはPRの作成)
on:
pull_request:
types: [labeled] # 今回はlabeledに限定
jobs:
build:
# 今回はラベルがついているかどうかのみをチェック(条件に合致しない場合にはjobをスキップ)
if: contains(join(github.event.pull_request.labels.*.name), 'request review')
runs-on: ubuntu-latest
steps:
- uses: 8398a7/action-slack@v1
with:
payload: |
{ "text": "please review ```${{ github.event.pull_request.title }}\n${{ github.event.pull_request.html_url }}```" }
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
GitHub Actions では自由にプログラムを書けるように、式内で利用できる組み込みの関数が存在しています。contains
関数もその一つで、以下のようなシンタックスで search
内に item
が含まれている場合に、true
を返します。
contains(search, item)
join
関数も組み込み関数の一つで、配列を指定したセパレーターで結合して文字列にします。
join(array, optionalSeparator)
また、ifの条件節を使うことで、指定した条件に合致する場合にのみ job を実行することができます。今回の場合であれば、複数個のラベルが付けられて、その中に request review
というラベルがあった場合にこのワークフローの steps が実行される、という仕組みとなります。
with
を使って、key/value ペアの入力パラメータを指定することができ、uses
で指定しているアクション 8398a7/action-slack@v1
に定められた入力パラメータを渡すことできます。今回の場合であれば、Slack に通知する内容を JSON 形式で payload
という名前の変数として渡しているわけです。
PRタイトルの問題
さて、ここから本題になりますが、バックティック3つを使って Slack 上でコードブロックとして PR のタイトルと URL を表示させるように、payload
では以下のように指定していました。
{ "text": "please review ```${{ github.event.pull_request.title }}\n${{ github.event.pull_request.html_url }}```" }
この payload だと PR のタイトルにダブルクォーテーション("
)が含まれていると落ちてしまうという問題があります。ダブルクォーテーションが入ったタイトルを持つ PR は CI でこのワークフローが落ちることになります。これは JSON 形式でダブルクォーテーションは特別な意味を持つ文字であり、このままダブルクォーテーションが入り込むと JSON のパースに失敗してしまうことが問題でした。
このような特別な文字は適切にエスケープしてあげることで問題とならなくなります。この場合であれば、"
を \"
にエスケープする必要があります。具体的には以下のように steps にエスケープを行うためのステップを追加してあげることで解決できそうです。
jobs:
build:
if: contains(join(github.event.pull_request.labels.*.name), 'request review')
runs-on: ubuntu-latest
steps:
+ - name: escape double quotes
+ id: escape
+ run: |
+ PR_TITLE="${{ github.event.pull_request.title }}"
+ ESCAPED_PR_TITLE="${PR_TITLE//\"/\\\"}"
+ echo "::set-output name=title::$ESCAPED_PR_TITLE"
- uses: 8398a7/action-slack@v1
with:
payload: |
- { "text": "please review ```${{ github.event.pull_request.title }}\n${{ github.event.pull_request.html_url }}```" }
+ { "text": "please review ```${{ steps.escape.outputs.title }}\n${{ github.event.pull_request.html_url }}```" }
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run
ステップでは、指定した OS のシェルを使って CLI プログラムを実行させることができます。上記のコードでは、シェルのローカル変数 PR_TITLE
と ESCAPED_PR_TITLE
に一時的に PR タイトルの値を保存していますね。"${PR_TITLE//\"/\\\"}"
のところでエスケープを行っています。
ちなみに、この書き方はシェルのパラメータ展開機能を使っており、文字列内の特定の文字を置換するということをやっています。${variable//pattern/replacement}
のフォーマットで、variable
変数の値で、pattern
に一致する部分をすべて replacement
で置き換えます。"${PR_TITLE//\"/\\\"}"
ではダブルクォーテーションで囲んでいるので、その中のダブルクォーテーションはエスケープする必要があり、pattern
は \"
で、replacement
はさらにエスケープして \\\"
のようになります。期待される置換は以下のようになります。
ダブルクォーテーションの入った"PR"タイトル
↓ 期待される置換
ダブルクォーテーションの入った\"PR\"タイトル
これで一見良さそうですが、このワークフローには問題があります。実際に PR タイトルにダブルクォーテーションを付けたものを作成してみると、CI 落ちはなくなりますが、Slack 通知されるタイトルではダブルクォーテーションが消えてしまうという問題が発生します。
環境ファイルの書き方
また、GitHub で CI を見てみると、以下のような警告文が表示されます。これは echo "::set-output name=title::$ESCAPED_PR_TITLE"
のような書き方が非推奨になっているという旨を伝えており、新しい書き方にするように言われています。
The `set-output` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
ということで、まずは古い書き方ではなく、新しい環境ファイルの書き方を使って環境変数の設定を行うように修正します。
jobs:
build:
if: contains(join(github.event.pull_request.labels.*.name), 'request review')
runs-on: ubuntu-latest
steps:
- name: escape double quotes
id: escape
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
ESCAPED_PR_TITLE="${PR_TITLE//\"/\\\"}"
- echo "::set-output name=title::$ESCAPED_PR_TITLE"
+ echo "title=$ESCAPED_PR_TITLE" >> "$GITHUB_OUTPUT"
- uses: 8398a7/action-slack@v1
with:
payload: |
{ "text": "please review ```${{ steps.escape.outputs.title }}\n${{ github.event.pull_request.html_url }}```" }
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
これで CI からの警告はなくなります。
ダブルクォーテーションの問題
一方、上記のように新しい書き方に変更してもダブルクォーテーションが消えてしまうという一番の問題はなくなりません。CI が落ちないのでダブルクォーテーション自体の置換はできているようですが、以下のように元のダブルクォーテーションは消えてしまいます。
ダブルクォーテーションの入った"PR"タイトル
↓ 実際の置換
ダブルクォーテーションの入ったPRタイトル
この問題は非常にやっかいで、以下のGitHubコミュニティのDiscussionでも挙げられています。結論としては、GitHub actions の中間環境変数 (intermediate environment variable) というものを最初にロードして使用することで適切にエスケープした文字列を Slack に通知することができます。
jobs:
build:
if: contains(join(github.event.pull_request.labels.*.name), 'request review')
runs-on: ubuntu-latest
steps:
- name: escape double quotes
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
id: escape
run: |
- PR_TITLE="${{ github.event.pull_request.title }}"
ESCAPED_PR_TITLE="${PR_TITLE//\"/\\\"}"
echo "title=$ESCAPED_PR_TITLE" >> "$GITHUB_OUTPUT"
- uses: 8398a7/action-slack@v1
with:
payload: |
{ "text": "please review ```${{ steps.escape.outputs.title }}\n${{ github.event.pull_request.html_url }}```" }
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
まず、基本として env
を使うことで、特定の step で利用したい環境変数をセットすることができます。この中間環境変数 PR_TITLE
を step の最初の記述し github.event.pull_request.title
の値を保存しておくことで、run
のシェルスクリプト実行において適切にその値を使うことができ、正常にエスケープできるようになります。
なぜそのようなことをしないといけないかといえば、修正した以下の箇所のコードでダブルクォーテーションが入っている場合にうまく、表現しきれないということがおきるからです。
PR_TITLE="${{ github.event.pull_request.title }}"
GitHub Actions では、${{ expression }}
の形式で式を利用できました。式では、コンテキストへの参照というものが行えます。コンテキストはプロパティを含むオブジェクトの形式で、GtiHub Actions のワークフローの実行やランナーの環境、ジョブ、ステップなどについての情報にアクセスする方法となっています。この場合のコンテキストは github.event.pull_request.title
のことで、github コンテキストと呼ばれるものへの参照を行い、PR タイトルの値を読み出しています。
さて、run
ではシェルスクリプトを実行しているわけですが、PR タイトルにスペースやら特殊な文字が入っていると、これをシェルで解釈すると最初のスペースまでの単語のみが変数にセットされるなど、やっかいなことになります。それを避けるためにも、${{ github.event.pull_request.title }}
をダブルクォーテーションで囲って単一の文字列として正確に扱えるようにしています。以下のようにしてしまうと、その問題が発生してしまいます。
PR_TITLE=${{ github.event.pull_request.title }}
しかし、ダブルクォーテーションを使って囲むと、今度は PR タイトル自体にダブルクォーテーションが入っていた場合に当該のダブルクォーテーションが消えてしまう問題が発生します。echo
を使って実際に確認したところ、シェルスクリプトで PR_TITLE
というローカル変数に保存した時点で以下のようにダブルクォーテーションが消失していました。
"ダブルクォーテーションの入った"PR"タイトル"
↓ PR_TITLE変数に展開される時点でダブルクォーテーションが消える
"ダブルクォーテーションの入ったPRタイトル"
そもそもコンテキストの参照段階でうまくダブルクォーテーションの表現ができてないため、後続のシェルスクリプトをいくらこねくりまわしてて問題は解決できません。ということで、中間環境変数を利用して、環境変数にコンテキストの値を一旦保存すると、ダブルクォーテーションの消失を避けて、適切にシェルスクリプトで文字列を扱えるようになります。この中間環境変数という方法は、元々ユーザー入力からスクリプトインジェクションを防ぐための方法として紹介されていますが、コンテキストをシェルスクリプトで利用しようとする場合にはこのような方法を使ってサニタイズを行うことが可能です。
env:
# 中間環境変数の使用
PR_TITLE: ${{ github.event.pull_request.title }}
id: escape
run: |
ESCAPED_PR_TITLE="${PR_TITLE//\"/\\\"}"
echo "title=$ESCAPED_PR_TITLE" >> "$GITHUB_OUTPUT"
おわり
ということで、GitHub Actions の簡単な説明と、PR タイトルのエスケープおよびその問題について解説してみました。なかなか解決するのに時間がかかったので、参考になれば幸いです。
それでは。