GitHub Actionsのjobはmatrixがexcludeにより空になった場合も実行されてしまう

# TL;DR;

GitHub Actionsのjobはmatrixを使うことで、複数の入力条件で実行できる。
また、条件として列挙されたうちの一部をexcludeで除外することができる。

しかし、excludeによってmatrixの要素数が0になると、matrixの値が空の状態で実行されてしまう。
多くの場合、これは意図しない動作になるだろう。

jobの実行は防げないので、jobの中のstepでmatrixの値をチェックして個別の処理をスキップする必要がある。

# 背景

複数のリソースを管理しているリポジトリで

- diffのあったリソースのみデプロイする
- ただし、特定のリソースは2つある環境(stageとproduction)のうち一方にだけデプロイする

というワークフローを書いていた。
例えば、こんな構成になっていて、

```
resources/
    a/ # stageとproduction両方
    b/ # stageとproduction両方
    c/ # stageのみ
```

- aに変更があったときは、aをstageとproductionにリリースする
- cに変更があったときは、cをstageにリリースする

といった具合である。
これを実現するために、

1. 変更のあったディレクトリを検知
1. 変更したディレクトリのリストをmatrixの要素として受け取りstageのデプロイを実行
1. 変更したディレクトリのリストをmatrixの要素として受け取りproductionのデプロイを実行
    - ただし、excludeでcを指定

というワークフローを書いた。
そしてcに変更があった場合、期待する動きは

1. 変更のあったディレクトリとしてcが検知
1. 変更したディレクトリのリストとして `["c"]` をmatrixとして受け取り、stageにcをデプロイ
1. 変更したディレクトリのリストとして `["c"]` をmatrixとして受け取るが、cはexcludeで指定されてmatrixは空なので何も実行せず終了

だった。
しかし、実際には

1. 変更のあったディレクトリとしてcが検知
1. 変更したディレクトリのリストとして `["c"]` をmatrixとして受け取り、stageにcをデプロイ
1. 変更したディレクトリのリストとして `["c"]` をmatrixとして受け取るが、cはexcludeで指定されてmatrixは空なのだが、要素として空の値(null)が指定されて1回実行される

という挙動となった。
その結果、本来、a,b,cのいずれかを期待しているjobはエラーを起こし、failedで完了することとなった。

## コードイメージ

そのコードを直接載せるのは差し支えがあるし、そもそも見づらいので、同様の問題が発生するシンプルなコードを掲載する。

```yaml:deploy.yaml
name: Empty matrix deploy

on:
  pull_request:
    branches:
      - main

jobs:
  # 対象のリソースを取得する(ここでは固定でcを取得しているが、本来は動的に決まるもの)
  get_target_resources:
    runs-on: ubuntu-latest
    outputs:
      resources: ${{ steps.get_resources.outputs.resources }}
    steps:
      - name: get resources
        id: get_resources
        run: |
          echo 'resources=["c"]' >> $GITHUB_OUTPUT

  # stageのデプロイ(すべてのリソースをデプロイする)
  deploy_stage:
    runs-on: ubuntu-latest
    needs: get_target_resources
    strategy:
      matrix:
        resource: ${{ fromJson(needs.get_target_resources.outputs.resources) }}
    steps:
      # matrix.resourcが"c"で実行されるので、 "deploy resource: [c]" と出力される
      - name: deploy resource
        run: |
          echo "deploy resource: [${{ matrix.resource }}]"

  # productionのデプロイ(cはデプロイしないので、本来は実行されてほしくないのだが、resourceの値がnullで実行されてしまう)
  deploy_production:
    runs-on: ubuntu-latest
    needs: get_target_resources
    strategy:
      matrix:
        resource: ${{ fromJson(needs.get_target_resources.outputs.resources) }}
        exclude:
          - resource: "c"
    steps:
      # matrix.resourcがnullで実行されるので、 "deploy resource: []" と出力されてしまう
      - name: deploy resource
        run: |
          echo "deploy resource: [${{ matrix.resource }}]"
```

# とりあえずの解決方法

調べた限り、このケースにjobの実行を防ぐすべは見当たらなかった(知っている人がいたらぜひ教えてほしい)。
matrixの値にnullが入ることを利用して、以下のようにstepの中でmatrixの値をチェックし、nullでない場合のみ実行するようにすれば、意図しない(つまりmatrixの値がnullでの)実行は防ぐことができる。

```yaml
  deploy_production_workaround:
    runs-on: ubuntu-latest
    needs: get_target_resources
    strategy:
      matrix:
        resource: ${{ fromJson(needs.get_target_resources.outputs.resources) }}
        exclude:
          - resource: "c"
    steps:
      # nullでない場合のみ実行する
      - name: deploy resource
        if: ${{ matrix.resource != null }}
        run: |
          echo "deploy resource: [${{ matrix.resource }}]"
```

# 補足: matrixとして受け取る値が空の場合

excludeによって空になるのではなく、そもそもmatrixに入る配列が空になるようなケースの対応はこちらで紹介されている。
前のjobで作った配列(になるJSON文字列)をチェックし、空と見なされるようなケースはifで除くというやり方である。

https://github.com/dorny/paths-filter/issues/66

```yaml:抜粋
test:
  needs: changes
  if: ${{ needs.changes.outputs.services != '[]' && needs.changes.outputs.services != '' }}
  strategy:
    matrix:
      service: ${{ fromJson(needs.changes.outputs.services) }}
```

一応補足しておくと、この方法はexcludeで空になるようなケースでは使えない。

```yaml
test:
  needs: changes
  if: ${{ matrix.service != null }}
  strategy:
    matrix:
      service: ${{ fromJson(needs.changes.outputs.services) }}
```

のような書き方をすると、このスコープではmatrixが使えないため、
`Unrecognized named-value: 'matrix'`
というエラーが出て実行できない。