github actions(w/z composite) + setup-terraform in 2023

# tl;dr

- [terraform Advent Calendar 2023](https://qiita.com/advent-calendar/2023/terraform) の2日目の記事です
- [去年の記事](https://qiita.com/raki/items/593db574795af798c704)では setup-terraform v2 を使用していましたが、2023年10月に v3 になったので最新化しました（毎年してる）
- [hashicorp/setup-terraform](https://github.com/hashicorp/setup-terraform) の個人用（兼業務用）ワークアラウンドです
- いろいろ削るのが面倒になったのでフルセット載せることにした
- issue や PR のコメントに直接差分を出力するとサイズ制限に引っかかるので GITHUB_STEP_SUMMARY に出力するようにした

# 注意

- apply はさせてなかったんだけどワークフロー側で選択できるようにした（画面から実行する時にも選択可）
- 一応動いてるけど、無保証です
- コードの手直し、質問等歓迎です

# composite action yaml

```.github/actions/terraform/action.yml
name: "setup-terraform"
description: 'terraform ci. fmt,init,validate,plan,apply'
# see: https://github.com/hashicorp/setup-terraform

inputs:
  terraform_version:
    description: 'use terraform version'
    required: false
    default: latest
  terraform_apply:
    description: 'run terraform apply'
    required: false
    default: "no"
    # "yes" の場合に apply を実行する
  secrets_github_token:
    description: 'secrets.GITHUB_TOKEN'
    required: true
    default: ""
  working-directory:
    description: 'run directory'
    required: true
    default: ""

outputs:
  plan_diff:
    description: "plan has diff"
    value: ${{ steps.plan.outputs.exitcode }}
    # plan 差分があったら 0 以外の値が返る

runs:
  using: "composite"
  steps:

    - uses: hashicorp/setup-terraform@v3
      with:
        terraform_version: ${{ inputs.terraform_version }}

    - id: fmt
      run: terraform fmt -check -diff 2>&1 | tee -a terraform_fmt_output.txt
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      continue-on-error: true
      # see: https://developer.hashicorp.com/terraform/cli/commands/fmt

    - id: init
      run: terraform init -no-color 2>&1 | tee -a terraform_init_output.txt
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      continue-on-error: true
      # see: https://developer.hashicorp.com/terraform/cli/commands/init

    - id: validate
      run: terraform validate -no-color 2>&1 | tee -a terraform_validate_output.txt
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      continue-on-error: true
      # see: https://developer.hashicorp.com/terraform/cli/commands/validate

    - id: plan
      run: terraform plan -no-color -detailed-exitcode -input=false 2>&1 | tee -a terraform_plan_output.txt
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      continue-on-error: true
      # see: https://developer.hashicorp.com/terraform/cli/commands/plan

    - id: apply
      if: ${{ inputs.terraform_apply == 'yes' }}
      run: terraform apply -no-color -input=false -auto-approve 2>&1 | tee -a terraform_apply_output.txt
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      continue-on-error: true
      # see: https://developer.hashicorp.com/terraform/cli/commands/apply

    - name: Create Comment
      uses: actions/github-script@v6
      if: ${{ always() && github.event_name == 'pull_request'}}
      with:
        github-token: ${{ inputs.secrets_github_token }}
        script: |
          // 見やすいようにアイコンを表示する
          const icon_result_fmt      = "${{ steps.fmt.outcome      }}" == "success" ? ":blue_heart:" : ":broken_heart:";
          const icon_result_init     = "${{ steps.init.outcome     }}" == "success" ? ":blue_heart:" : ":broken_heart:";
          const icon_result_validate = "${{ steps.validate.outcome }}" == "success" ? ":blue_heart:" : ":broken_heart:";

          // plan 差分を判断するため exitcode を使用する
          const icon_result_plan     = "${{ steps.plan.outputs.exitcode }}" == 0 ? ":blue_heart:"  : ":broken_heart:";
          const plan_exitcode_str    = "${{ steps.plan.outputs.exitcode }}" == 0 ? "& No changes"  : "but any changes !!";

          // apply
          let icon_result_apply = '';
          if ( "${{ inputs.terraform_apply }}" === 'yes') {
            icon_result_apply = "${{ steps.apply.outcome }}" == "success" ? ":blue_heart:" : ":broken_heart:";
          }

          // PR にコメントする内容
          const output = `${{ matrix.dir }} ${ icon_result_fmt } ${ icon_result_init } ${ icon_result_validate } ${ icon_result_plan } ${ icon_result_apply }

          plan is ${{ steps.plan.outcome }} ${ plan_exitcode_str }
          see [details](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
          `;

          github.rest.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: output.substring(0,60000)
          })


    - name: Create Summary
      id: summary
      if: always()
      shell: bash
      run: |
        # output to GITHUB_STEP_SUMMARY
        echo '<details><summary>terraform fmt is ${{ steps.fmt.outcome }}</summary>' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        touch terraform_fmt_output.txt
        cat   terraform_fmt_output.txt | sed '/^::/d' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '</details>' >> $GITHUB_STEP_SUMMARY
        #
        echo '<details><summary>terraform init is ${{ steps.init.outcome }}</summary>' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        touch terraform_init_output.txt
        cat   terraform_init_output.txt | sed '/^::/d' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '</details>' >> $GITHUB_STEP_SUMMARY
        #
        echo '<details><summary>terraform validate is ${{ steps.validate.outcome }}</summary>' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        touch terraform_validate_output.txt
        cat   terraform_validate_output.txt | sed '/^::/d' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '</details>' >> $GITHUB_STEP_SUMMARY
        #
        if [[ "${{steps.plan.outputs.exitcode}}" == "0" ]]
        then
          echo '<details><summary>terraform plan is ${{ steps.plan.outcome }}</summary>' >> $GITHUB_STEP_SUMMARY
        else
          echo '<details><summary>terraform plan is ${{ steps.plan.outcome }} and any changes</summary>' >> $GITHUB_STEP_SUMMARY
        fi
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        touch terraform_plan_output.txt
        cat   terraform_plan_output.txt | sed '/^::/d' >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '```'        >> $GITHUB_STEP_SUMMARY
        echo ''           >> $GITHUB_STEP_SUMMARY
        echo '</details>' >> $GITHUB_STEP_SUMMARY
        #
        if [[ "${{inputs.terraform_apply}}" == "yes" ]]
        then
          echo '<details><summary>terraform apply is ${{ steps.apply.outcome }}</summary>' >> $GITHUB_STEP_SUMMARY
          echo ''           >> $GITHUB_STEP_SUMMARY
          echo '```'        >> $GITHUB_STEP_SUMMARY
          touch terraform_apply_output.txt
          cat   terraform_apply_output.txt | sed '/^::/d' >> $GITHUB_STEP_SUMMARY
          echo ''           >> $GITHUB_STEP_SUMMARY
          echo '```'        >> $GITHUB_STEP_SUMMARY
          echo ''           >> $GITHUB_STEP_SUMMARY
          echo '</details>' >> $GITHUB_STEP_SUMMARY
        fi
      working-directory: ${{ inputs.working-directory }}


    - name: terraform plan -json
      id: plan_json
      # Schedule の時だけ実行する。drift を検知するために json 出力して精査する
      if: ${{ github.event_name == 'schedule' }}
      run: |
        terraform plan -json | tee -a terraform_plan_output.json
        cat terraform_plan_output.json | sed '/^::/d' | jq -crR 'fromjson?' > output.json
        # count https://developer.hashicorp.com/terraform/internals/machine-readable-ui
        echo "errors=$(  cat output.json | jq -crR 'fromjson? | select(."@level" == "error")'       | jq -s 'length')" >> $GITHUB_OUTPUT
        echo "warnings=$(cat output.json | jq -crR 'fromjson? | select(."@level" == "warn")'        | jq -s 'length')" >> $GITHUB_OUTPUT
        echo "changes=$( cat output.json | jq -crR 'fromjson? | select(.type? == "planned_change")' | jq -s 'length')" >> $GITHUB_OUTPUT
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      continue-on-error: true

    - name: Find Issue
      id: find_issues
      if: ${{ github.event_name != 'pull_request' }}
      uses: actions-cool/issues-helper@v3
      with:
        actions: 'find-issues'
        token: ${{ inputs.secrets_github_token }}
        issue-creator: 'github-actions[bot]'
        issue-state: 'open'
        title-includes: "Check: ${{ matrix.dir }} terraform plan diff"
      continue-on-error: true

    - name: Close issue if no drift
      if: steps.find_issues.outputs.issues &&
          fromJSON(steps.find_issues.outputs.issues)[0] != null && (
            steps.plan_json.outputs.errors == 0 &&
            steps.plan_json.outputs.warnings == 0 &&
            steps.plan_json.outputs.changes == 0 &&
            steps.plan.outputs.exitcode == 0
          )
      uses: actions-cool/issues-helper@v3
      with:
        actions: 'close-issue'
        token: ${{ inputs.secrets_github_token }}
        issue-number: ${{ fromJSON(steps.find_issues.outputs.issues)[0].number }}
        close-reason: 'completed'
      continue-on-error: true

    - name: Create comment add an detected issue
      if: steps.find_issues.outputs.issues &&
          fromJSON(steps.find_issues.outputs.issues)[0] != null && (
            steps.plan_json.outputs.errors != 0 ||
            steps.plan_json.outputs.warnings != 0 ||
            steps.plan_json.outputs.changes != 0 ||
            steps.plan.outputs.exitcode != 0
          )
      uses: actions-cool/issues-helper@v3
      with:
        actions: 'create-comment'
        token: ${{ inputs.secrets_github_token }}
        issue-number: ${{ fromJSON(steps.find_issues.outputs.issues)[0].number }}
        body: |
          まだ差分のままのようです。
          see [details](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
      continue-on-error: true

    - name: Create issue if drift detected
      if: ${{ always() && (
            github.event_name != 'pull_request' &&
            steps.find_issues.outputs.issues &&
            fromJSON(steps.find_issues.outputs.issues)[0] == null && (
              steps.plan_json.outputs.errors != 0 ||
              steps.plan_json.outputs.warnings != 0 ||
              steps.plan_json.outputs.changes != 0 ||
              steps.plan.outputs.exitcode != 0
            )
          ) }}
      uses: actions-cool/issues-helper@v3
      with:
        actions: 'create-issue'
        token: ${{ inputs.secrets_github_token }}
        title: "Check: ${{ matrix.dir }} terraform plan diff"
        labels: sre, terraform
        body: |
          ${{ matrix.dir }} で差分があるので確認してください
          see [details](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
      continue-on-error: true


    - name: Job status
      uses: actions/github-script@v6
      with:
        script: |
          if ("${{ steps.fmt.outcome }}" != "success") {
            core.setFailed('terraform fmt error.');
          }
          if ("${{ steps.init.outcome }}" != "success") {
            core.setFailed('terraform init error.');
          }
          if ("${{ steps.validate.outcome }}" != "success") {
            core.setFailed('terraform validate error.');
          }

          if ("${{inputs.terraform_apply}}" === "yes") {
            if ("${{ steps.apply.outcome }}" === "success") {
              // apply が success なら plan 差分はエラーではない（もちろん apply も）
              core.notice('OK: terrafom plan has diff. but applied.');
            } else {
              // apply がエラーだった場合はエラー
              core.setFailed('terraform apply error.');
            }
          } else {
            // apply は処理しないケースで plan について
            if ("${{steps.plan.outputs.exitcode}}" != "0") {
              core.setFailed('terraform plan error.');
            }
          }

# note:
# - step の continue-on-error: true でステップを継続する。付けてないところでエラーになると後続のステップはスキップされる
# - if: always() でエラーやスキップがあっても常にコメント作成する処理をする
# - createComment の body 部は（ドキュメントに記載はないが）サイズ制限があるので
#   大きな出力結果の時にエラーになるため substring() で抑止
# - 最後の bash でワークフローのエラーを決めている。fmt がエラーでもチェックOKにするなら
#   （すべきではないと思うが） exit 1 をコメントにすればよい
#   apply を追加したので処理順を変えていることに注意
# - actions/github-script の script 中で環境変数を呼び出した際に、
#   規定以上の長さの文字列だとエラーで落ちてしまうため、処理内容は全て summary で出力することにした

```

# workflow yaml

```.github/workflows/pr.yml
name: workflow_test

on:
  workflow_dispatch:
    inputs:
      terraform_apply:
        description: 'run terraform apply'
        required: true
        default: "no"
  schedule:
    - cron: '0 0 * * MON'
  pull_request:
    paths:
      - ".github/workflows/pr.yml"
      - "workflow_test/dir1/*.tf"
      - "workflow_test/dir2/*.tf"

jobs:
  tf:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      actions: read
      contents: read
      id-token: write
      issues: write
      pull-requests: write
    strategy:
      fail-fast: false
      matrix:
        dir:
          - "workflow_test/dir1"
          - "workflow_test/dir2"
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ vars.AWS_ROLE_OIDC_ARN }}
      - name: terraform
        uses: ./.github/actions/terraform
        with:
          secrets_github_token: ${{ secrets.GITHUB_TOKEN }}
          working-directory: ${{ matrix.dir }}
          terraform_apply: ${{ inputs.terraform_apply }}
```

# まとめ

- 不自由なく使えている
- 手動実行時にapplyするかを入力できるようにしたおかげで、定期実行時に適用すれば済む程度の差分が出た場合にもしゅっと対応できるようになった
- そういえば AWS への接続を OIDC にしたのでセキュリティ面でも強化された（OIDCのARNは機密情報とは言わないと思うのでvarsを使っているが、ベタ書きするのと未だに迷ってる）

# おまけ

- 昨年は composite にしてると dependabot が反応しないからバージョンアップに難がある旨書いたけど解消した
- [こんな感じ](https://twitter.com/raki/status/1720304964147490920)

```.github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions" # See documentation for possible values
    directory: "/" # Location of package manifests
    schedule:
      interval: "weekly"
  - package-ecosystem: "github-actions"
    directory: "/.github/actions/terraform"
    schedule:
      interval: "weekly"

```