GitHub Actionsの式と型

# はじめに

GitHub Actionsを使い込んでいると、リファレンスに明示されていない部分が気になることが多々ある (個人の見解です) 。
タスクに忙殺されていると「動くから良し」の判断のもと、碌な検証や検討がされていない不適切な実装のままWorkflowが運用されてしまうこともしばしばある (個人の見解です) 。
より深くGitHub Actionsの仕様を理解し、より適切に実装するということがスムーズに行えるよう、調査と結果をこの記事に記録する。

検証環境はGitHub Enterprise Server 3.14.4 である。github.comではない点に注意されたい。


# 式

まず前提として、GitHub Actionsでは「式」が書ける。
https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions

式として以下が書ける。
* 即値 (リテラル)
* 単項演算 ( `!` )
* 二項演算 ( `<` `<=` `>` `>=` `==` `!=` `&&` `||` )
* 関数呼び出し
* プロパティ参照 ※右辺にはプロパティ名しかとれない
* context

また `( 式 )` も式であり、式の評価順序を明示することができる。

## 型

式を評価すると値になる。
値は以下のいずれかの型に分類できる
* null
* boolean
* string
* number
* array
* object

## 暗黙変換

値はいくつかの文脈でいくつかの型へ暗黙変換される。
例えば、異なる型同士の比較演算を行う場合に両辺が number へ暗黙変換される。

### string への暗黙変換

* `null` -> 空文字列
* `false` -> `'false'`
* `true` -> `'true'`
* number -> 数値を10進数表記した文字列、桁数が大きい場合は指数表記となる
* 配列、オブジェクト -> 変換はできず、エラーを生じる

### number への暗黙変換

* `null` -> `0`
* `false` -> `0`
* `true` -> `1`
* 文字列
  * 空文字列 -> `0`
  * JSONにおける数値表現を満たす文字列 -> その文字列を数値へ変換した結果
  * その他の文字列 -> `NaN`
* 配列、オブジェクト -> `NaN`

### falsy values と non-falsy values

暗黙変換とは少し性質が異なるが、すべての値は falsy values か non-falsy values に分類される。
falsy values に分類される値は boolean として評価する文脈において `false` 相当の振る舞いをする。
falsy values でない値はすべて non-falsy values に分類され `true` 相当の振る舞いをする。

falsy values に分類されるのは以下の値。
* `false`
* `null`
* `0` 、 `-0`
* 空文字列

## リテラル

リテラルとして以下の型の値を記述できる。
* boolean : `true` または `false`
* null : `null`
* number : JSONでサポートされるフォーマットの数値
* string : シングルクォート `'` で括った文字列、シングルクォートをエスケープする場合は2つ連続させる

以下はリテラルを用いて環境変数を定義する例。
```
env:
  myNull: ${{ null }}
  myBoolean: ${{ false }}
  myIntegerNumber: ${{ 711 }}
  myFloatNumber: ${{ -9.2 }}
  myHexNumber: ${{ 0xff }}
  myExponentialNumber: ${{ -2.99e-2 }}
  myString: Mona the Octocat
  myStringInBraces: ${{ 'It''s open source!' }}
```

なお、リファレンスでは単純な文字列について `${{ '文字列' }}` の形式ではなく直接文字列を記述する、と記載がある。
個人的には `${{ '文字列' }}` の形式の方にする方が、統一性があり良いのではないかと思う。

## 関数

式として関数呼び出しを記述できる。
https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#functions

なお一部の関数は特定の文脈でしか定義できない為注意が必要である。
https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#context-availability

リファレンスには `Some functions cast values to a string to perform comparisons.` との記載がある。
`to perform comparisons` の意味するところが判然としないが、 `join` 関数の第2引数と `toJSON` 関数の第1引数以外のすべての引数は関数呼び出し時に string へ暗黙変換されているように思う。

いくつかの関数が提供されているが、この記事では特に `fromJSON(value)` と `toJSON(value)` に注目する。
`fromJSON(value)` は `value` をJSON文字列として解釈し、そのオブジェクトや値を返す関数である。
逆に `toJSON(value)` は `value` をJSON文字列に変換し、その文字列を返す関数である。

`fromJSON(value)` はJSONやJavaScriptで扱える以下の型の値を返す。
* string
* boolean
* null
* array
* object

`fromJSON(value)` を用いることでリテラルでは直接表現できない型の値も表現することができる。

## オブジェクト

GitHub Actionsではオブジェクトに対し `[ ]` 演算子によるインデックスアクセスと、 `.` 演算子によるプロパティ参照の2つの方法でプロパティの参照ができる。
`.` 演算子では右辺にプロパティ名しか取れないが、`[ ]` 演算子の場合にはインデックスに式を取ることができ、より複雑な表現が可能である。

```
env:
  # Error: The template is not valid. <workflow path> (Line: N, Col: M): A mapping was not expected
  # SAMPLE_MY_VAR_1: ${{ fromJSON( '{"hoge":"value"}' ) }}
  SAMPLE_MY_VAR_1: ${{ fromJSON( '{"hoge":"value"}' )['hoge'] }}
  SAMPLE_MY_VAR_2: ${{ fromJSON( '{"hoge":"value"}' ).hoge }}
```

※環境変数の定義では string への暗黙変換が発生するため、 `SAMPLE_MY_VAR_1` の定義はエラーを生じる

存在しないプロパティを参照した場合は `null` が返る。
これは [context のリファレンス](https://docs.github.com/en/enterprise-server@3.14/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#about-contexts)の記載 `If you attempt to dereference a nonexistent property, it will evaluate to an empty string.` とは明らかに異なる。

また、 `null` に対するプロパティの参照はエラーとはならず、 `null` を返す。

```
env:
  SAMPLE_MY_VAR_1: ${{ fromJSON( '""' ) }}
  SAMPLE_MY_VAR_2: ${{ toJSON(fromJSON( '""' )) }}
  SAMPLE_MY_VAR_3: ${{ null }}
  SAMPLE_MY_VAR_4: ${{ toJSON(null) }}
  SAMPLE_MY_VAR_5: ${{ fromJSON( '{}' ).hoge }}
  SAMPLE_MY_VAR_6: ${{ toJSON(fromJSON( '{}' ).hoge) }}
  SAMPLE_MY_VAR_7: ${{ github.hoge }}
  SAMPLE_MY_VAR_8: ${{ toJSON(github.hoge) }}
  SAMPLE_MY_VAR_9: ${{ toJSON(fromJSON( 'null' ).hoge) }}
```

上記の例の評価結果は以下の通りになる。
```
SAMPLE_MY_VAR_1: 
SAMPLE_MY_VAR_2: ""
SAMPLE_MY_VAR_3: 
SAMPLE_MY_VAR_4: null
SAMPLE_MY_VAR_5: 
SAMPLE_MY_VAR_6: null
SAMPLE_MY_VAR_7: 
SAMPLE_MY_VAR_8: null
SAMPLE_MY_VAR_9: null
```

## 配列

配列に対し `[ ]` 演算子によるインデックスアクセスができる。
(リファレンスに記載がないため推定だが)インデックスは number へ暗黙に変換される。
インデックスに対応する要素が無い場合は null を返す。

```
 env:
  # Error: The template is not valid. <workflow path> (Line: N, Col: M): A sequence was not expected
  # SAMPLE_MY_VAR_1: ${{ fromJSON( '["A", "B", "C"]' ) }}
  SAMPLE_MY_VAR_2: ${{ fromJSON( '["A", "B", "C"]' )['1'] }} # B
  SAMPLE_MY_VAR_3: ${{ fromJSON( '["A", "B", "C"]' )[2] }} # C
  SAMPLE_MY_VAR_4: ${{ fromJSON( '["A", "B", "C"]' )[false] }} # A
  SAMPLE_MY_VAR_5: ${{ fromJSON( '["A", "B", "C"]' )[''] }} # A
  SAMPLE_MY_VAR_6: ${{ fromJSON( '["A", "B", "C"]' )[null] }} # A
  SAMPLE_MY_VAR_7: ${{ toJSON(fromJSON( '["A", "B", "C"]' )[3]) }} # null
  SAMPLE_MY_VAR_8: ${{ toJSON(fromJSON( '["A", "B", "C"]' )[-1]) }} # null
```

※環境変数の定義では string への暗黙変換が発生するため、 `SAMPLE_MY_VAR_1` の定義はエラーを生じる

実際に動作させて確認した限り、配列はプロパティを持っていなかった。
そのため、ある配列の要素数を GitHub Actions の式で知る術がない。

## 比較演算

典型的な6種の比較演算 `<` `<=` `>` `>=` `==` `!=` が利用できる。

リファレンスには比較演算に関して重要な仕様が記載されている。
* 両辺の型が一致しない場合、 number へ暗黙変換を行ってから比較演算を行う
* `NaN` に対する比較演算は、比較対象の値に拠らず常に `false` となる
* string の比較を行う場合、大文字小文字は区別されない
* オブジェクトと配列は、同一インスタンスの場合にのみ等価と扱われる

string 同士の比較は大文字小文字が区別されない以外、文字コード順の比較に思える。
単純な例で、以下の結果が得られた。
文字コードに対する取り扱いがどのようになっているかについては調査、確認していない。
(たとえばWorkflowの定義をShift-JISで記述し、Unicodeに変換できない文字列リテラルを記述して比較演算を行う場合には何が起きるのだろうか。)

```
env:
  SAMPLE_MY_VAR_1: ${{ 'A' < 'B' }} # true
  SAMPLE_MY_VAR_2: ${{ 'A' == 'B' }} # false
  SAMPLE_MY_VAR_3: ${{ 'A' > 'B' }} # false
  SAMPLE_MY_VAR_4: ${{ 'A' < 'a' }} # false
  SAMPLE_MY_VAR_5: ${{ 'A' == 'a' }} # true
  SAMPLE_MY_VAR_6: ${{ 'A' > 'a' }} # false
```

オブジェクトや配列に対しては、同一のインスタンスであるかという比較のみ行うことができる。
そのため、以下のような結果が得られる。

```
env:
  SAMPLE_MY_VAR_1: ${{ fromJSON('{"key":"A"}') == fromJSON('{"key":"A"}') }} # false
  SAMPLE_MY_VAR_2: ${{ fromJSON('{"key":"A"}') <= fromJSON('{"key":"B"}') }} # false
  SAMPLE_MY_VAR_3: ${{ fromJSON('{"key":"A"}') >= fromJSON('{"key":"B"}') }} # false
  SAMPLE_MY_VAR_4: ${{ fromJSON('["A"]') == fromJSON('["A"]') }} # false
  SAMPLE_MY_VAR_5: ${{ fromJSON('["A"]') <= fromJSON('["B"]') }} # false
  SAMPLE_MY_VAR_6: ${{ fromJSON('["A"]') >= fromJSON('["B"]') }} # false
  SAMPLE_MY_VAR_7: ${{ github == github }} # true
  ```

## 論理演算

論理積 `&&` と論理和 `||` が利用できる。
論理演算の評価結果は boolean ではなく両辺のどちらかの値となる。
この性質により、論理演算は三項演算子の代わりとして利用できる。

```
env:
  SAMPLE_MY_VAR_1: ${{ (inputs.value == 'hoge') && 'fuga' || 'piyo' }}
```

この例の場合、条件式 `(inputs.value == 'hoge')` の評価結果が真であれば SAMPLE_MY_VAR_1 は `'fuga'` 、そうでなければ `'piyo'` となる。

```
env:
  SAMPLE_MY_VAR_1: ${{ inputs.flag && 0 || 1 }}
  SAMPLE_MY_VAR_2: ${{ inputs.flag && inputs.value1 || inputs.value2 }}
```

論理演算を用いた疑似的な三項演算は、真値 (条件式が真だった場合に返したい値) が falsy value とならないよう注意して扱う必要がある。
この例の SAMPLE_MY_VAR_1 は、 `0` が falsy value であるため `inputs.flag` の値に拠らず `1` で定義されてしまう。
また SAMPLE_MY_VAR_2 も、 `inputs.value1` が空文字列の場合それは falsy value であり、 `inputs.flag` の値に拠らず `inputs.value2` で定義されてしまう。

# context

Workflow の run や runner 、リポジトリ変数等々を Workflow 定義で参照する手段として context が提供されている。
https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs

全ての context があらゆる文脈で利用できるわけでは無いため、注意する必要がある。
https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#context-availability

context はオブジェクトとして扱える。
通常はそのプロパティを参照するために用いる。

## inputs と型

Workflow は inputs 属性によって入力を与えることができる。
Workflow の入力は on.workflow_call.inputs.\<input_id\> または on.workflow_dispatch.inputs.\<input_id\> で定義される。

Workflow の入力は context を用いて `inputs.<input_id>` として参照できる。
`github.event.inputs.<input_id>` としても参照できるが、こちらは値が常に string であることに注意する必要がある。

## inputs の型の指定

Workflow の入力は on.\<workflow_call|workflow_dispatch\>.inputs.\<input_id\>.type でその型を指定できる。
しかし workflow_call と workflow_dispatch では、有効な選択肢や指定による効果が大きく異なる。

|typeの値|workflow_call|workflow_dispatch|
|---|---|---|
|(指定が必須か?)|必須|任意 (デフォルトは string 指定)|
|string|inputs.\<input_id\>が必ず string となる。|inputs.\<input_id\>が string か null となる。|
|boolean|inputs.\<input_id\>が必ず boolean となる。|inputs.\<input_id\>が必ず boolean となる。|
|number|inputs.\<input_id\>が必ず number となる。|inputs.\<input_id\>が string か null となる。数値に変換不可能な文字列を与えてもそのまま受理される。|
|choice|選択不可。|on.workflow_dispatch.inputs.\<input_id\>.optionsで定義した選択肢からなるドロップダウンリストで入力を選択可能となる。inputs.\<input_id\>が必ず string となる。|
|environment|選択不可。|リポジトリに定義された environment からなるドロップダウンリストで入力を選択可能となる。inputs.\<input_id\>が必ず string となる。|

workflow_call の場合、 type 指定とその効果が明確で扱いやすい。
一方 workflow_dispatch の場合、特に number を指定した場合はその挙動に注意が必要である。
数値であることのバリデーションが行われないどころか、そもそも string で値が返ってくる。

workflow_call の場合、必ず type 指定に従った型の値が定義されるのに対し、 workflow_dispatch の場合一部の指定で null を返すケースがある。
これは値が渡されなかった場合に、そもそも inputs.\<input_id\> が定義されない為に生じる。
特に入力フォームが空の場合、空文字列が定義されるのではなく、プロパティが定義されず参照すると null が返ることに注意が必要である。

## inputs の型検査

workflow_call の入力は uses.with 属性で与えることとなる。
このとき、 inputs の型が期待されたものであるか検査され、型の不一致に対してエラーを吐くケースがある。

type で string を指定した場合、 string への暗黙変換が成功すれば受理される。
これに対して boolean と number を指定した場合、それ以外の型の値を渡すと `The workflow is not valid.` というエラーによって job の起動に失敗する。
特に step や job の outputs は必ず string に暗黙変換され定義されることから、以下のようなワークフロー呼び出しは失敗することに注意が必要である。

```
jobs:
  first_job:
    # outputs は string に暗黙変換される
    outputs:
      string_value: ${{ 'ABC' }}
      number_value: ${{ 123 }}   # '123' と等価
      boolean_value: ${{ true }} # 'true' と等価

    steps:
      - run: echo ""

  second_job:
    needs: [ first_job ]
    uses: ./.github/workflows/XXX.yml
    with:
      string_input: ${{ needs.first_job.outputs.string_value }} # string 指定に string が渡る => OK
      number_input: ${{ needs.first_job.outputs.number_value }} # number 指定に string が渡る => Error
      boolean_input: ${{ needs.first_job.outputs.boolean_value }} # boolean 指定に string が渡る => Error
```

それだけでは100%の動作を保証できないが、型を一致させてエラーを回避するために `fromJSON(value)` を用いる方法がある。
`string_value` は元から string の為 `fromJSON(value)` を用いてはいけない (不正なJSONのパースとしてエラーとなる) 。
```
    with:
      string_input: ${{ needs.first_job.outputs.string_value }}
      number_input: ${{ fromJSON(needs.first_job.outputs.number_value) }}
      boolean_input: ${{ fromJSON(needs.first_job.outputs.boolean_value) }}
```