🕌

(1/2)CI/CD GitHub Actionsでインフラを自動デプロイ!

に公開

今までの記事

目次

作りたいもの

CI/CDパイプライン構築

GitHub Actions tag昇格管理

インフラ/infra feature push

dev fmt/validate/tflint 既存PRにコメント追加→fmt/validate/tflintがsuccessならdevのplan実行

インフラ/infra main マージ

dev:plan apply

インフラ/infra タグ昇格

stg:v*.*.*-rc* plan→手動承認→apply
prd:v*.*.* plan→手動承認→apply

ディレクトリ構造

0603game/
├── public/                    # 静的サイト(ゲーム)
│   ├── index.html            # メイン HTML
│   ├── game.js               # ゲームロジック
│   └── styles.css            # スタイル
├── infra/                     # Infrastructure as Code
│   ├── backend-setup/        # Terraform state管理
│   │   ├── environments/     # 環境別設定
│   │   └── modules/          # 共通モジュール
│   ├── static-website/       # Webサイトインフラ
│   │   └── environments/     # dev/stg/prd設定
│   └── mod/modules/          # 再利用可能モジュール
│       ├── acm-certificate/  # SSL証明書自動発行
│       ├── cloudfront-oac/   # CloudFront + OAC
│       ├── route53-records/  # DNS管理
│       └── s3-private-bucket/ # プライベートS3
├── .github/                   # GitHub 設定
│   └── workflows/            # CI/CD パイプライン
│       ├── deploy-to-s3.yml  # public/ を S3 に同期するデプロイ(push/手動実行想定・静的サイト配信)
│       ├── terraform-ci-feature-lint-draftpr.yml  # feature/** で Lint/Validate/TFLint+dev向け plan/自動 Draft PR
│       ├── terraform-deploy-dev-on-main.yml       # main への push で dev に plan→apply(OIDC AssumeRole)
│       ├── terraform-deploy-prd-on-tag.yml        # tag v*.*.* で prd に plan→承認後 apply(本番リリース)
│       └── terraform-deploy-stg-on-tag.yml        # tag v*.*.*-rc* で stg に plan→承認後 apply(リリース候補)
└── CLAUDE.md                  # 開発ガイドライン

AWSとの安全な接続設定 (OIDC)

フェーズ1:GitHub ActionsでのOIDCのお勉強

GitHub側が対象のAWSへデプロイするときに認証情報が必要になる
通常アクセスキーなどを使うが、使わなくてもいいよりセキュアな認証方式

なぜこの仕組みが必要か?

従来はAWSのアクセスキーとシークレットキーをGitHubのシークレットに保存していたが、これはリスクがあります。

  • キーが漏洩するリスク
  • キーの定期的な更新が必要
  • 長期間有効な認証情報の管理が煩雑
    OIDCを使うことでパスワードなしで安全に認証出来るようになる

ステップ1:トークンを要求(GitHub→OIDC)

GitHub Actionsのワークフローが実行されると、GitHubのOIDCプロバイダーに認証トークンを要求する。

ステップ2:JWTトークン発行(OIDC→GitHub)

GitHub OIDCプロバイダーが、そのワークフローの身元を証明するJWT(JSON Web Token)を発行します。このトークンにはリポジトリ名、ブランチ名、ワークフロー情報などが含まれています。
この発行されたトークンは一意のものであり、この実行でしか発行されない。

ステップ3:AWSへの認証要求(GitHub→STS)

GitHub Actionsは受け取ったJWTトークンを使って、AWS STS(Security Token Service)にAssumeRoleWithWebIdentityAPIを呼び出します。

ステップ4:トークン検証(STS)

AWS STSがJWTトークンの正当性を確認する。
署名が正しいか、有効期限内か、発行元が信頼出来るか。
つまり、このトークンを発行したActionsが正しいか確認する。

ステップ5:信頼関係確認(STS→IAM→STS)

IAMロールの信頼ポリシーを確認し、GitHub Actionsからのアクセスが許可されているか検証する。

ステップ6:一時認証情報の発行(STS→GitHub)

確認したのは、GitHubのActionsが要求したものか確認
IAMロールの信頼ポリシーで信頼されてるか確認
検証が成功すると、AWS STSが一時的な認証情報を発行する。(アクセスキー、シークレットキー、セッショントークン)

ステップ7:環境変数への設定(GitHub)

GitHub Actionsが受け取った一時認証情報を環境変数に設定し、後続のAWS CLIやSDKの操作で使用する。

メリット

セキュリティ向上: 長期的な認証情報を保存する必要がない
自動管理: トークンは自動的に期限切れになる
監査性: どのワークフローがいつアクセスしたか追跡しやすい
権限の細分化: リポジトリやブランチごとに異なる権限を設定可能

フェーズ2:踏み台アカウントにIDプロバイダの作成

Terraformと同様に踏み台アカウントからデプロイする。

踏み台アカウントのIAM→IDプロバイダーからプロバイダを追加する
OpenID Connectを選択し、プロバイダのURLは下記の公式ドキュメントから
https://docs.github.com/ja/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws
プロバイダURL:https://token.actions.githubusercontent.com
対象者:sts.amazonaws.com

フェーズ3:GitHub Actions専用IAMロールの作成

GitHub Actionsからの操作を受け取る踏み台アカウントのロールを作成する

IAM→ロール→ロールを作成
信頼されたエンティティをウェブアイデンティティを選択
アイデンティティプロバイダー: 登録したIDプロバイダーを選択
Audience: 上記の対象者を選択
GitHub organization: 名前を記載
GitHub repository: リポジトリを限定したい場合は記載
GitHub branch: ブランチを限定したい場合は記載

フェーズ4:ポリシーを作成し、ポリシー付与

対象ロールARNのAssumeRoleを許可するポリシーを作成
そして作成したロールに、このポリシーをアタッチする。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": [
                "arn:aws:iam::002540791269:role/TerraformExecutionRole-prd",
                "arn:aws:iam::086266612383:role/TerraformExecutionRole-stg",
                "arn:aws:iam::330723288310:role/TerraformExecutionRole-dev"
            ]
        }
    ]
}

フェーズ5:各ターゲットアカウントでポリシー編集

各アカウントにTerraform実行用のロールを作成していて、そのロールは踏み台アカウントからのAssumeを信頼してるので、そのロールに上記で作成したロールARNを追加する。

これをdev,stg,prdで実施する

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::318574063927:role/aws-reserved/sso.amazonaws.com/ap-northeast-1/AWSReservedSSO_0603game-TerraformOperator_c6f75dbe8448194d",
                    "arn:aws:iam::318574063927:role/GitHubActionsHubRole"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

TerraformCI/CDパイプライン構築

フェーズ1:GitHubActionsとは

GitHub Actions ドキュメント

GitHubが提供するCI/CD(継続的インテグレーション/継続的デプロイ)サービス。コードの変更に応じて自動的にビルド、テスト、デプロイなどのタスクを実行出来る。
GitHub Actionsの主要概念

  • Workflow: 自動化されたプロセス全体
  • Event: ワークフローを起動するトリガー
  • Job: 一連のステップをグループ化
  • Step: 個々のタスク
  • Runner: ジョブを実行する仮想マシン
  • Action: 再利用可能なタスクの単位

.github/workflows/

このディレクトリはGitHubが定義しているディレクトリで、この中にYAMLファイルでワークフローの内容を記載していく

フェーズ2:/infra feature push用ワークフローを作成

feature push用に.github/workflows/terraform-ci-featuer-lint-draftpr.ymlを作成

terraform-ci-feature-lint-draftpr.yml
name: Infra Feature Push (Lint + Plan dev via Hub)

on:
  push:
    branches: ['feature/**']
    paths:
      - 'infra/**'
      - '.tflint.hcl'
      - '.terraform-version'
      - '.github/workflows/terraform-ci-feature-lint-draftpr.yml'

# 既定は最小権限。Plan系のジョブで id-token 等を追加
permissions:
  contents: read
  pull-requests: write
  issues: write

env:
  TF_PLUGIN_CACHE_DIR: /home/runner/.terraform.d/plugin-cache
  TFLINT_PLUGIN_DIR: /home/runner/.tflint.d/plugins
  TFLINT_VERSION: v0.53.0
  AWS_REGION: ap-northeast-1
  HUB_ACCOUNT_ID: "318574063927"    # ← ハブ(踏み台)アカウント ID
  WORKDIR_DEV: "infra/static-website/environments/dev"

concurrency:
  group: lint-plan-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Lint/Validate/TFLint + 既存PRへチェック結果コメント ----
  lint-summary:
    if: ${{ github.actor != 'github-actions[bot]' }}
    runs-on: ubuntu-latest
    outputs:
      lint_ok: ${{ steps.gate.outputs.lint_ok }}
      pr_number: ${{ steps.check-pr.outputs.number }}
      fmt: ${{ steps.collect.outputs.fmt }}
      validate: ${{ steps.collect.outputs.validate }}
      tflint: ${{ steps.collect.outputs.tflint }}

    steps:
      - uses: actions/checkout@v4

      - name: Cache Terraform plugins
        uses: actions/cache@v4
        with:
          path: ${{env.TF_PLUGIN_CACHE_DIR}}
          key: ${{ runner.os }}-tfplugins-${{ hashFiles('**/.terraform.lock.hcl') }}
          restore-keys: ${{ runner.os }}-tfplugins-

      - name: Cache TFLint plugins
        uses: actions/cache@v4
        with:
          path: ${{ env.TFLINT_PLUGIN_DIR }}
          key: ${{ runner.os }}-tflint-${{ hashFiles('.tflint.hcl', '**/.tflint.hcl') }}
          restore-keys: ${{ runner.os }}-tflint-

      - name: Create plugin cache dir
        run: |
          mkdir -p ${{ env.TF_PLUGIN_CACHE_DIR }}
          mkdir -p ${{ env.TFLINT_PLUGIN_DIR }}
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.2

      - uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: ${{ env.TFLINT_VERSION }}

      - name: Terraform Format Check
        id: fmt
        working-directory: ${{ env.WORKDIR_DEV }}
        run: terraform fmt -check -recursive 
        continue-on-error: true

      - name: Terraform Validate (backend=false)
        id: validate
        run: |
          dir="${{ env.WORKDIR_DEV }}"
          if [ -d "$dir" ]; then
            echo "::group::Validating $dir"
            pushd "$dir" > /dev/null
            terraform init -backend=false -input=false -lockfile=readonly
            terraform validate -no-color
            popd > /dev/null
            echo "::endgroup::"
          fi
        continue-on-error: true

      - name: TFLint
        id: tflint
        working-directory: ${{ env.WORKDIR_DEV }}
        run: |
          tflint --init
          tflint --recursive
        continue-on-error: true

      # 👇 fmt/validate/tflint の outcome を後続ジョブへ渡す
      - name: Collect step outcomes
        id: collect
        run: |
          echo "fmt=${{ steps.fmt.outcome }}"       >> "$GITHUB_OUTPUT"
          echo "validate=${{ steps.validate.outcome }}" >> "$GITHUB_OUTPUT"
          echo "tflint=${{ steps.tflint.outcome }}" >> "$GITHUB_OUTPUT"

      # 👇 3つ全部 success のときだけ plan を許可するフラグを出力
      - name: Decide plan gate
        id: gate
        run: |
          ok=true
          [ "${{ steps.fmt.outcome }}" = "success" ] || ok=false
          [ "${{ steps.validate.outcome }}" = "success" ] || ok=false
          [ "${{ steps.tflint.outcome }}" = "success" ] || ok=false
          echo "lint_ok=$ok" >> "$GITHUB_OUTPUT"

      # 既存のPRを検出(作成はしない)
      - name: Check for existing PR
        id: check-pr
        uses: actions/github-script@v7
        with:
          script: |
            const branch = context.ref.replace('refs/heads/', '');
            const { data: prs } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              head: `${context.repo.owner}:${branch}`,
              base: 'main',
              state: 'open'
            });
            core.setOutput('number', prs.length ? prs[0].number.toString() : '');

      # PRがドラフトでないなら警告だけ(任意)
      - name: Warn if PR is not Draft
        if: steps.check-pr.outputs.number != ''
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = parseInt('${{ steps.check-pr.outputs.number }}');
            const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: prNumber
            });
            if (!pr.draft) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                body: `⚠️ **注意**: このPRはまだレビュー準備が完了していない可能性があります。\nTerraformのチェック結果を確認してください。`
              });
            }
        continue-on-error: true

      # 既存PRへチェック結果サマリをコメント
      - name: Create Check Summary text
        id: summary
        run: |
          {
            echo "### 🔍 Terraform Check Results"
            echo
            echo "| Check | Status |"
            echo "|-------|--------|"

            if [ "${{ steps.fmt.outcome }}" = "success" ]; then
              echo "| Format   | ✅ Pass |"
            else
              echo "| Format   | ❌ Fail |"
            fi

            if [ "${{ steps.validate.outcome }}" = "success" ]; then
              echo "| Validate | ✅ Pass |"
            else
              echo "| Validate | ⚠️ Warning |"
            fi

            if [ "${{ steps.tflint.outcome }}" = "success" ]; then
              echo "| TFLint   | ✅ Pass |"
            else
              echo "| TFLint   | ⚠️ Warning |"
            fi
          } > summary.md

          {
            echo "summary<<EOF"
            cat summary.md
            echo "EOF"
          } >> "$GITHUB_OUTPUT"


      - name: Comment on existing PR
        if: steps.check-pr.outputs.number != ''
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = parseInt('${{ steps.check-pr.outputs.number }}');
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: `### 🔄 新しいコミットがプッシュされました

            **Commit:** \`${context.sha.substring(0, 7)}\`
            **Author:** @${context.actor}
            **Branch:** \`${context.ref.replace('refs/heads/', '')}\`

            ${{ steps.summary.outputs.summary }}

            **詳細:** [Actions Run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
            });

      - name: Add Job Summary
        if: always()
        run: |
          echo "## 📊 Terraform CI Results" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "${{ steps.summary.outputs.summary }}" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          if [ "${{ steps.check-pr.outputs.number }}" != "" ]; then
            echo "### 📌 Pull Request" >> $GITHUB_STEP_SUMMARY
            echo "- PR: #${{ steps.check-pr.outputs.number }}" >> $GITHUB_STEP_SUMMARY
            echo "- Status: Existing PR updated with comment" >> $GITHUB_STEP_SUMMARY
          else
            echo "### 📌 Pull Request" >> $GITHUB_STEP_SUMMARY
            echo "- Status: No open PR. (Creation disabled)" >> $GITHUB_STEP_SUMMARY
          fi

  # ---- dev だけ Terraform Plan(Hub→dev ロールチェーン)。lint成功時のみ ----
  plan-dev:
    needs: [lint-summary]
    if: ${{ needs.lint-summary.outputs.lint_ok == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      issues: write
      id-token: write

    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false

      # ✅ Terraform を 1.9.2 でセットアップ
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.2

      # ✅ jq をインストール(バージョン表示は任意)
      - name: Install jq
        run: |
          sudo apt-get update
          sudo apt-get install -y jq
          echo "Using jq: $(jq --version)"

      # (任意・安定化)キャッシュディレクトリを先に作成
      - name: Create plugin cache dir
        run: mkdir -p "${{ env.TF_PLUGIN_CACHE_DIR }}"

      - name: Cache Terraform plugins
        uses: actions/cache@v4
        with:
          path: ${{ env.TF_PLUGIN_CACHE_DIR }}
          key: ${{ runner.os }}-tfplugins-${{ hashFiles('**/.terraform.lock.hcl') }}
          restore-keys: ${{ runner.os }}-tfplugins-

      # 1) まずハブアカウントに OIDC で Assume
      - name: Assume Hub (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.HUB_ACCOUNT_ID }}:role/GitHubActionsHubRole
          role-session-name: gha-hub-${{ github.run_id }}-${{ github.actor }}
          aws-region: ${{ env.AWS_REGION }}

      # init(既定)
      - name: Terraform Init (backend in DEV)
        working-directory: ${{ env.WORKDIR_DEV }}
        run: terraform init -input=false -lockfile=readonly -upgrade=false

      - name: Terraform Plan (dev)
        id: tfplan
        working-directory: ${{ env.WORKDIR_DEV }}
        run: |
          set -eo pipefail
          terraform plan -no-color -input=false -lock-timeout=60s -out=tf.plan | tee plan.txt
          terraform show -json tf.plan > tfplan.json

          creates=$(jq '[.resource_changes[]? | select(.change.actions | index("create"))] | length' tfplan.json)
          replaces=$(jq '[.resource_changes[]? | select(.change.actions | index("replace"))] | length' tfplan.json)
          updates=$(jq '[.resource_changes[]? | select(.change.actions | index("update"))] | length' tfplan.json)
          deletes=$(jq '[.resource_changes[]? | select(.change.actions | index("delete"))] | length' tfplan.json)

          creates=$((creates + replaces))
          deletes=$((deletes + replaces))

          echo "creates=$creates" >> $GITHUB_OUTPUT
          echo "updates=$updates" >> $GITHUB_OUTPUT
          echo "deletes=$deletes" >> $GITHUB_OUTPUT

      - name: Build plan comment (dev)
        id: body
        working-directory: ${{ env.WORKDIR_DEV }}
        run: |
          {
            echo "### Terraform Plan \`dev\`  (+${{ steps.tfplan.outputs.creates }} / ~${{ steps.tfplan.outputs.updates }} / -${{ steps.tfplan.outputs.deletes }})"
            echo
            echo "**Base:** \`main\`  | **Ref:** \`${GITHUB_SHA::7}\`  | **Dir:** \`${{ env.WORKDIR_DEV }}\`"
            echo
            echo "<details><summary>Show full plan</summary>"
            echo
            echo '```'
            cat plan.txt
            echo '```'
            echo
            echo "</details>"
          } > body.md

      - name: Find existing PR (for comment)
        id: find-pr
        uses: actions/github-script@v7
        with:
          script: |
            const branch = context.ref.replace('refs/heads/', '');
            const { data: prs } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              head: `${context.repo.owner}:${branch}`,
              base: 'main',
              state: 'open'
            });
            core.setOutput('number', prs.length ? prs[0].number.toString() : '');

      - name: Comment plan to PR (dev)
        if: steps.find-pr.outputs.number != ''
        run: gh pr comment ${{ steps.find-pr.outputs.number }} --body-file "${{ env.WORKDIR_DEV }}/body.md"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Attach plan snippet to Job Summary (dev)
        run: |
          echo "## 🧮 Plan dev (+${{ steps.tfplan.outputs.creates }} / ~${{ steps.tfplan.outputs.updates }} / -${{ steps.tfplan.outputs.deletes }})" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "<details><summary>Show full plan</summary>" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          sed -n '1,400p' "${{ env.WORKDIR_DEV }}/plan.txt" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "</details>" >> $GITHUB_STEP_SUMMARY

  # ---- Lint失敗時:Planをスキップした理由をPRにコメント(PRがある場合)----
  comment-why-skip:
    needs: [lint-summary]
    if: ${{ needs.lint-summary.outputs.lint_ok != 'true' && needs.lint-summary.outputs.pr_number != '' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Explain why plan was skipped
        run: |
          gh pr comment ${{ needs.lint-summary.outputs.pr_number }} --body "$(cat <<'MD'
          ### ⏭️ Plan skipped
          Lint/Validate/TFLint のいずれかが失敗したため、この push では **Terraform plan を実行していません**。

          - Format:  ${{ needs.lint-summary.outputs.fmt }}
          - Validate: ${{ needs.lint-summary.outputs.validate }}
          - TFLint:   ${{ needs.lint-summary.outputs.tflint }}

          失敗箇所を修正後、再 push してください。
          MD
          )"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

name: Feature Lint & Auto Draft PR

on:
  push:
    branches: ['feature/**']
    paths:
      - 'infra/**'
      - '.tflint.hcl'
      - '.terraform-version'
      - '.github/workflows/terraform-ci-feature-lint-draftpr.yml'
  • name: Feature Lint & Auto Draft PR: GitHub Actions上で表示される名前。
  • on: push: pushイベントで起動する。
  • branches: ['feature/**']:mainブランチへのpushのみ。
  • paths: 記載のファイルが変更された時のみ実行。
  • 'infra/**' この**2個は、何階層もって意味、*1個はその階層だけ。

上記の処理1ブロックの要約
ワークフローのトリガーを指定していて今回だと、pathsのファイルに変更があって、それがfeatureで始まるブランチがプッシュ or マージされた時に発動するような指定となっている。

permissions:
  contents: read
  pull-requests: write
  issues: write

env:
  TF_PLUGIN_CACHE_DIR: ~/.terraform.d/plugin-cache
  TFLINT_VERSION: v0.53.0

concurrency:
  group: lint-${{ github.ref }}
  cancel-in-progress: true
  • permissions:: 権限の設定。
  • contents: read: リポジトリの読み取り権限。
    • (Github Actions内にダウンロードするのに必要)。
  • pull-requests: write: PR作成・更新。
  • issues: write: Issueの管理。
  • env:: 環境変数を指定する。
  • export TF_PLUGIN_CACHE_DIR=~/.terraform.d/plugin-cache: Terraformのプロバイダープラグインのキャッシュディレクトリを指定する。
  • TFLINT_VERSION: v0.53.0: TFLintのバージョンを固定化する環境変数。
  • Concurrency: 実行の順番を制御する。
    • これが無いと連続でpushしたとき順番関係無くワークフローが開始される。
  • group: lint-${{ github.ref }} Concurrency(同時実行制御)のグルーピングキー。
    • lintはただのプレフィックス。
      • github.refはプッシュしたものが格納されている。
        • (ブランチやタグ)これらをキーとして同時実行制御を実施する。
  • cancel-in-progress: true: これがtrueの場合は、Aが途中でも同じグループのBが開始されたらキャンセルして、Bが始まる。
    • つまり一番最後のワークフローが完了となる。
      • falseの場合はAが途中なら、Aが完了まで待ってから。Bが開始される。

上記の処理1ブロックの要約
権限の設定は、リポジトリの読み取り権限、PRの作成・更新、Issueの管理の権限を与えていて、環境変数はTFLintのディレクトリの指定と、バージョン指定を行っている。また同時実行制御を同じブランチ(今回のワークフロー実行の際のトリガー)に対して行っている。

jobs:
  lint-and-draft-pr:
    if: ${{ github.actor != 'github-actions[bot]' }}
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
  • jobs:: ジョブの定義。
  • lint-and-draft-pr:: ジョブID(内部参照用)。
  • if: ${{ github.actor != 'github-actions[bot]' }}: github.actorはワークフローを起動した人。
    • github-actions[bot] はgithubがワークフローを起動した場合。
  • runs-on: ubuntu-latest: 実行環境。
  • steps:: ジョブ内で順番に実行される個別のタスク。
    • 各ステップは独立したプロセス、スクリプトを実行する。
  • uses: actions/checkout@v4: リポジトリをubuntu-latestへダウンロードするアクション名。
    • @4はアクションのバージョン名。
      • GitHubランナーは空っぽのため、操作するリポジトリを入れる必要がある。

上記の処理1ブロックの要約
このジョブ全体の処理を行うかどうかの条件分岐で決めている。プッシュをしたのがユーザーなのか、GitHubの処理であるbotなのか、処理内容がbotでプッシュの処理があればループしてしまうのでジョブの実行はユーザーのみに限定している。

      # Terraformプラグインキャッシュ
      - name: Cache Terraform plugins
        uses: actions/cache@v4
        with:
          path: ~/.terraform.d/plugin-cache
          key: ${{ runner.os }}-tfplugins-${{ hashFiles('**/.terraform.lock.hcl') }}
          restore-keys: ${{ runner.os }}-tfplugins-

  • uses: actions/cache@v4: GitHub Actionsの実行間で任意のディレクトリやファイルをキャッシュして復元する公式アクション。
    • 依存モジュールやビルド生成物をとっておけば、次回以降の実行を高速化する。
  • with:: そのアクションに渡す入力パラメータを指定する場所。
    path: ~/.terraform.d/plugin-cache: Terraformのグローバルなキャッシュ格納場所。
  • key: ${{ runner.os }}-tfplugins-${{ hashFiles('**/.terraform.lock.hcl') }} キーでキャッシュ検索されて、ヒットすればキャッシュ復元。
  • SHA-256ハッシュ: ファイルの中身から64桁の16進数の数値が出力される。入力が同じなら必ず出力は同じ。
    • しかし、出力から元の入力を復元するのが実質不可能。
      • これをキーの一部とすることでファイルの中身が同じかを判別する。
  • restore-keys: ${{ runner.os }}-tfplugins- これは完全ヒットじゃなくても、一部ヒットでもOKとするバックアップキーのようなもの。

キャッシュの流れ
最初はキャッシュ検索し、キャッシュは無くて、terraform init(ダウンロード)が行われてキーとして保存される。
次に同じ依存関係のワークフロー実行があると、キャッシュ検索でヒットし、完全一致のためキャッシュ復元されterraform init(ダウンロード)されることなく高速となる。保存はされずにスキップされる。
依存関係が更新された場合は、完全一致はせずに次に一部ヒットで古いのを発見し、古いキャッシュから一部再利用し差分のみダウンロードされる。保存する場合は新しいキーとして保存される。
一部再利用し、差分のみダウンロードはTerraformの機能として備わっている。
GitHub Actionsでの仕様上キャッシュは不変であるため、上書き保存などは出来ない。
TFLint: 文法チェックだけでは無く、プロバイダー固有のルール(存在しないインスタンスタイプ等)、ベストプラクティス、組織固有のポリシーなどを解析するTerraformコードの静的解説ツール。
【(TFLink)】

上記の処理1ブロックの要約
Terraformのキャッシュ場所とキーを指定している。これがないと毎回terraform initなどで時間が掛かることになる。

      # ディレクトリ作成(キャッシュ用)
      - name: Create plugin cache dir
        run: mkdir -p ~/.terraform.d/plugin-cache
      
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.2
      
      - uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: ${{ env.TFLINT_VERSION }}
  • name: Create plugin cache dir: キャッシュされるディレクトリの作成。
    • ツール実行する前にディレクトリの作成はしておく。
  • run: mkdir -p ~/.terraform.d/plugin-cache: Runner上で実行されるコマンド。
  • uses: hashicorp/setup-terraform@v3: GitHub Marketplaceで用意されてる。Terraform CLIのセットアップを行うためのアクション。
  • with:: そのアクションに渡す入力パラメータを指定する場所。
  • terraform_version: 1.9.2: バージョンを指定する。
  • uses: terraform-linters/setup-tflint@v4: GitHub Marketplaceで用意されてる、TFLinkのセットアップを行うためのアクション。

上記の処理1ブロックの要約
TFLinkのキャッシュ指定と、TerraformとTFLinkのセットアップ処理を実行。

      # Terraform fmt チェック
      - name: Terraform Format Check
        id: fmt
        run: terraform fmt -check -recursive infra/
        continue-on-error: true
  • run: terraform fmt -check -recursive infra/: terraform fmtフォーマットチェック。
    • -checkオプションチェックのみ。
      • -recursiveオプションサブディレクトリも含めて再帰的にチェック。
  • continue-on-error: true: フォーマットにエラーがあってもワークフローを継続。
    • GitHub Actionsの固有の機能。

上記の処理1ブロックの要約
terraform fmfの実行コマンド。
現在のコードのフォーマットチェックのコマンド。

      # Terraform validate(lockfile読み取り専用)
      - name: Terraform Validate
        id: validate
        run: |
          for env in dev stg prd; do
            dir="infra/static-website/environments/$env"
            if [ -d "$dir" ]; then
              echo "::group::Validating $dir"
              pushd "$dir" > /dev/null
              terraform init -backend=false -input=false -lockfile=readonly
              terraform validate -no-color
              popd > /dev/null
              echo "::endgroup::"
            fi
          done
        continue-on-error: true
  • name: Terraform Validate: GitHub Actionsのstep名を定義。
    • ログ上でTerraform Validateと表示される。
  • id: validate: このstepにvalidateと言うIDを付与し、後続のstepから結果を参照する際に使用出来る。
  • run: | for env in dev stg prd; do: envと言う変数にdev,stg,prdを代入。
    • doはここから繰り返し処理を開始と言う意味。
      • doneが繰り返し処理が終わりと言う意味。
        • doからdoneまでを繰り返し処理を行うと言う意味。
  • dir="infra/static-website/environments/$env": 処理中のディレクトリのパスを変数に格納。
if [ -d "$dir" ]; then
  • if: 条件分岐。true(真)とelse(偽)で処理を分けれる。
  • [ ]: testコマンドの短縮系。
  • -d: ディレクトリかどうかの判定オプション。
  • "$dir" 変数dirの値を展開。
    • ""で空白や改行も含めた値が展開される。
  • ;: 改行の代わりに使用。
  • then: if文の必須キーワード。
    • 条件が真の場合に実行する部分の開始場所。
  • echo "::group::Validating $dir": ::group::Validating $dirと言うテキストを出力する。
  • echo: テキストを出力。
  • ::group:: : GitHub Actions固有の記法。
  • Validating: 普通の文字列。
  • $dir: 変数展開。
  • pushd "$dir" > /dev/null: 最後には元に戻りたいため、pushdで移動するが、出力結果はノイズになるためブラックホールへリダイレクトする。
    • 標準エラーはエラー内容を見たいためそのままに。
  • pushd: 現在居るディレクトリを記憶してから(cdを何回しても1コマンドで戻れる)、対象のディレクトリへ移動する。
    • 記憶したディレクトリと、移動先のディレクトリも出力する。
  • >: リダイレクト。
    • 標準出力(他に標準エラーもあるよ)を対象のファイルへ書き込む
  • /dev/null: ブラックホールのような存在のファイル。
  • terraform init -backend=false -input=false -lockfile=readonly 後のチェック処理にて、構文チェックに加え、モジュール参照関係や、プロバイダごとの属性名や型のチェックのために必要となる処理。
  • terraform init: バックエンドの初期化(設定の読み込み)、モジュールダウンロード、
    プロバイダーダウンロード、.terraformディレクトリの作成。
  • -backend=false: バックエンド初期化 フェーズをスキップする。
    • つまり設定の再読み込みはされない。
  • -input=false: 対話モードをやめる設定。
    • 対話モードだとそこで止まってしまう。
      • 聞かれることがあるとエラーと表示されて処理が進む。
  • -lockfile=readonly: そもそもここの変更はRunner上なので失われる。
    • lock.hclに記載されたバージョンのみ使用し、予期しないアップグレードを防ぐ。
      • lock.hclファイルがない場合は作成されるし、プロバイダー情報とかの変更が記述されていればlock.hclファイルが作成される。
        • それを防ぐ。
  • terraform validate -no-color: HCL構文の正しさ、リソースの引数の妥当性、変数の型の一致、などなどをチェックする。
  • -no-color: ログにカラー情報が入るとノイズとなるためそれを無くす。
  • popd > /dev/null: pushdの時に記憶したディレクトリへ戻る。
    • 出力結果はブラックホールへ。
  • echo "::endgroup::" GitHubのログの記法の折りたたみの、折りたたみ終了地点。
  • fi ifの終了地点を示す。
    • ディレクトリの存在有無をチェックし真で処理を行う一連の流れの終了地点。
  • done: doの終了地点を示す。
    • 上記の繰り返しの終了地点。
  • continue-on-error: true: エラーがあっても処理を継続する。
    • GitHub Actions特有の機能。

上記の処理1ブロックの要約
dev stg prd分のterraform initとterraform validateを実行し、その出力結果を折りたたみのコメントで表示する処理。

      # TFLint実行
      - name: TFLint
        id: tflint
        working-directory: infra
        run: |
          tflint --init
          tflint --recursive
        continue-on-error: true
  • name: TFLint: GitHub Actionsのステップ名
  • id: tflint: このステップにtflintと言うIDを付与。
    • 後続のステップのsteps.tflint.outcomeなどで参照出来る。
  • working-directory: infra: コマンドを実行する場所が必ずinfraディレクトリとなる。
    • cdを使う際はもっと動的な処理の時に一時的な時に使う。
  • tflint --init: TFLintのダウンロード、初期化(セットアップ)を行う。
  • tflint --recursive: すべてのサブディレクトリを再帰的に検査する。
    • --recursiveオプションが無い時は、カレントディレクトリのみ。

上記の処理1ブロックの要約
infra配下のすべてのサブディレクトリを検査する処理。

      # チェック結果のサマリ作成
      - name: Create Check Summary
        id: summary
        run: |
          SUMMARY="### 🔍 Terraform Check Results\n\n"
          SUMMARY="${SUMMARY}| Check | Status |\n"
          SUMMARY="${SUMMARY}|-------|--------|\n"
          
          if [ "${{ steps.fmt.outcome }}" == "success" ]; then
            SUMMARY="${SUMMARY}| Format | ✅ Pass |\n"
          else
            SUMMARY="${SUMMARY}| Format | ❌ Fail |\n"
          fi
          
          if [ "${{ steps.validate.outcome }}" == "success" ]; then
            SUMMARY="${SUMMARY}| Validate | ✅ Pass |\n"
          else
            SUMMARY="${SUMMARY}| Validate | ⚠️ Warning |\n"
          fi
          
          if [ "${{ steps.tflint.outcome }}" == "success" ]; then
            SUMMARY="${SUMMARY}| TFLint | ✅ Pass |\n"
          else
            SUMMARY="${SUMMARY}| TFLint | ⚠️ Warning |\n"
          fi
          
          echo "summary<<EOF" >> $GITHUB_OUTPUT
          echo "$SUMMARY" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
  • id: summary: このステップにIDを付与。
    • 後続ステップからsteps.summary.outputs.xxxで出力値を参照出来るようにする。
  • run: |: run:GitHub Actionsでシェルコマンドを実行するキー。
  • SUMMARY=" ": SUMMARYと言う変数に文字列を入れてる。
  • \n\n: 改行2回。1つの改行と1つの空白行を作る。
  • SUMMARY="${SUMMARY}| Check | Status |\n" SUMMARY="${SUMMARY} 変数名と並びで、変数を追加する。
    • (### 🔍 Terraform Check Results\n\n${SUMMARY}| Check | Status |\n) タイトルがあって、空白行があって、テーブルのタイトルがある表記になる。
  • if [ "${{ steps.fmt.outcome }}" == "success" ]; then: stepのfmtのoutcomeがsuccessと同じ場合は真。
    • そうじゃないなら偽。
  • {{ }}: かっこが二重なのはGitHubの構文。
    • bashコマンドと混同されるので変数の展開は二重となる。
  • outcome: ステップの実行結果をGitHub側の処理結果を4種類の値を取る。
    • success=成功、failure=失敗、cancelled=キャンセル、skipped=スキップ。
  • ;: 式の内容を一区切り起こす。
  • then: if文の必須のキーワード。
    • 真の場合の処理開始地点 。
  • lse SUMMARY="${SUMMARY}| Format | ❌ Fail |\n": elseは偽の場合の処理開始地点。

以下同じような処理がvalidateとtflint分ある

  • echo "summary<<EOF" >> $GITHUB_OUTPUT:
  • echo: 文字出力コマンド。
  • $GITHUB_OUTPUT ステップ間の値の受け渡しに使う変数。
  • summary<<EOF: summaryが値で、<<は複数行の場合。
    • =は1行の場合。
      • EOFはマーカーの開始地点。
  • >>: 追記のリダイレクト。>は上書き。
  • echo "$SUMMARY" >> $GITHUB_OUTPUT$GITHUB_OUTPUTへメインの文章を追記。
  • echo "EOF" >> $GITHUB_OUTPUT GITHUB_OUTPUTへ最後尾の文章を追記。

上記の処理1ブロックの要約
terraform fmf、terraform validate、tflint --init、tflint --recursive、の結果がsuccessかどうかでGitHub上に表示する文言を指定して見やすくしている。

      # 既存のPRをチェック
      - name: Check for existing PR
        id: check-pr
        uses: actions/github-script@v7
        with:
          script: |
            const branch = context.ref.replace('refs/heads/', '');
            const { data: prs } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              head: `${context.repo.owner}:${branch}`,
              base: 'main',
              state: 'open'
            });
            
            if (prs.length > 0) {
              console.log(`Found existing PR #${prs[0].number}`);
              return prs[0].number;
            }
            return '';
          result-encoding: string
  • uses: actions/github-script@v7: github オブジェクトとcontext オブジェクトがパッケージとして内蔵されている。
  • github オブジェクト: 認証済みのAPIツールのようなもの。操作が出来る。
  • context オブジェクト: ワークフロー実行時の情報が含まれている。
  • with:: アクションへのパラメータを渡す開始しますと言う宣言のようなもの。
  • script: |: scriptパラメータに複数行のコードを渡しますと言う宣言のようなもの。
const branch = context.ref.replace('refs/heads/', '');
  • const: 定数を定義するときに宣言するようなもの。
  • branch: 変数名。しかし今回は定数。
  • context.ref: ワークフローをトリガーしたGit参照が入っている。
  • replace(): JavaScriptで使える文字列のメソッド。
    • 文字列内の特定の部分を別の文字列に置き換えるメソッド。
      • 【文字列.replace(検索対象, 置換文字)】
  • ('refs/heads/', '');: refs/heads/''空文字に置き換える。
    • 空文字は削除と同義。

ポイント
context.refのrefs/heads/部分を空文字にしたものを変数branchへ格納して、branchを定数とする。

const { data: prs } = await github.rest.pulls.list({
  • data: Octokit の戻り値(レスポンス)の中にさまざまなプロパティがあって、その中のdataを指定している。
  • data: prs: そのdataをprsに変数として代入している。
  • await: 非同期処理を始めますと言う宣言。
    • API完了を待機する。
      • JavaScriptの言語機能。
  • github.rest.pulls.list: Octokitのメソッドで、そのOctokitはactions/github-script@v7によって認証済みで使える。
    • その関数を呼び出せて、機能はプルリクエスト一覧。
owner: context.repo.owner,
  • context: GitHub Actionsの実行中ワークフローのメタ情報の塊(大きなオブジェクト)。
  • repo: その中のリポジトリに関する情報のオブジェクト。
  • owner: そのオブジェクトのパラメータownerに該当する値。

ポイント
github.rest.pulls.listの引数github.rest.pulls.listが受け付ける形で引数のオブジェクトを渡してる。
上記の感じで値を指定している。

repo: context.repo.repo,

repo.repoはリポジトリに関する情報オブジェクトのプロパティ名を指定。つまりリポジトリ名を指定

head: `${context.repo.owner}:${branch}`
  • head: PRの発信元を指定。baseはプルリクの宛先を指定。
  • ${context.repo.owner}: GitHub Actions実行中ワークフローの情報の中のリポジトリに関する情報オブジェクトの中のownerパラメータを指定。
  • ${branch}: 1行上のbranchへ格納した値を指定。
  • : PR発信元を指定するためのGitHub Actionsの公式フォーマット。
    • 【< オーナー名><:><ブランチ名>】
  • **`(バッククォート) と ${}:**${}`変数名を展開するための囲い。
    • バッククォートは変数展開2つを1つのものとして認識させる。
base: 'main'
state: 'open'

base: 'main':プルリクの宛先情報を指定。headはPRの発信元を指定。
state: 'open':プルリクが開いてるかどうか

if (prs.length > 0) {
  • if: 条件分岐式の制御文。
    • 今回はJavaScriptなのでthenは要らない。構文はif (条件) { 文 } else { 文 } fiとなる。
  • prs.length: prs内の配列の要素数を返す。
  • prs.length > 0: prs内の配列の要素数が0より大きい場合と言う意味。
    • つまりPRがあるかどうかを条件式で調べてる。
console.log(`Found existing PR #${prs[0].number}`);
  • console.log: 開発者向けにメッセージや変数の値を出力する関数。デバッグや動作確認のための最も基本的なツール。
    • GitHub Actions上のステップのログに表示される。
  • Found existing PR: これは文字列。
  • prs[0]: prs配列の1番目を指定。配列のインデックスは0から始まるため、0が1番目。
  • .number: 配列内の.number要素を指定し展開する。

console.logに『Found existing PR #000』のようなログを出力する処理。

  return '';
result-encoding: string
  • return '': returnはif関数の処理を終わらせて値を返す文。
    • 今回の場合はelseを省略してreturnを使用している。
      • 値を返すって言うのはGitHub Actions上の他ステップで再利用出来る値として保存しとける。
        • echo "Step1: [${{ steps.step1.outputs.result }}]"のように再利用可能になる。
  • result-encoding: string: その返り値の形式を指定できる。
    • JavaScriptの返り値をどのような形式で保存するかを指定するもの。

上記の処理1ブロックの要約
既存のPRが存在するかチェックをしている。
まずbranch変数にrefs/heads/を削除したcontext.refを代入して定数化して、github-scriptで呼べるOctokitのメソッドawait github.rest.pulls.listでPRを取得する。
その時の指定が、今回ワークフローを実行したオーナーとリポジトリ名を記述してどのリポジトリに対してPRを取得するAPIを実行するかを指定する。
そしてheadとbaseで、どのブランチからどのブランチに対してPRをしていてオープンになってるものを指定する。
そのAPIの返り値のdataをprsと言う変数に格納し、その変数を定数化する。
んで、取得したPRがないのであればそのまま処理は終わりで、あるならconsole.logに出力し、returnでプルリクのナンバーを文字列として返り値として返す。

      # 既存PRをDraftに戻す(オプション - チーム判断)
      - name: Ensure PR is Draft
        if: steps.check-pr.outputs.result != ''
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = parseInt('${{ steps.check-pr.outputs.result }}');
            const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: prNumber
            });
            
            if (!pr.draft) {
              // Draftに戻すかコメントで警告するか選択
              // Option 1: Draftに戻す
              /*
              await github.rest.pulls.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: prNumber,
                draft: true
              });
              console.log(`Reverted PR #${prNumber} to draft`);
              */
              
              // Option 2: 警告コメントのみ
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                body: `⚠️ **注意**: このPRはまだレビュー準備が完了していない可能性があります。\nTerraformのチェック結果を確認してください。`
              });
            }
        continue-on-error: true
  • if: steps.check-pr.outputs.result != '': 1ブロック上の処理でreturnとして返した値を参照してifで!=(イコールじゃない)、つまり空白じゃなければ真、空白なら偽。
    • つまりPRがあればこれらの処理を実行。PRがなければこれらの処理を飛ばす。
      • GitHub Actionsでの処理で偽の記述が無くても自動でそのブロックの処理を飛ばす。
  • with: uses:で指定したアクションに渡すパラメータを指定する場所の宣言みたいなもの。
  • script: パラメータに複数行のコードを渡しますと言う宣言のようなもの。
  • |: は改行をそのまま保持すると言う指定。
    • JavaScriptを複数行で書きたい時に最適になる。
const prNumber = parseInt('${{ steps.check-pr.outputs.result }}');
  • ${{ }} GitHub Actionsの変数展開。
    • ${}だと他の記述と被るので2重にする。
  • parseInt(): 文字列を数値に変換。
    • 今回の場合だとname: Check for existing PRで返り値に指定したPRの番号を文字列として返してたので、『123』と言う文字列を『123』と言う数値に変換。
      • JavaScriptの組み込み関数。
  • const prNumber = parseInt: その数値をprNumberと言う変数に格納して、constでprNumberを定数とする。

ポイント
name: Check for existing PRでのPRの.numberを返り値としてて、その値を文字列として返してたので、それを数値にして変数格納して、それを定数とした。

const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: prNumber
            });
  • pull_number: prNumber:pull_numberと言うプロパティにprNumberを指定している。
    • これらは関数に渡すためのオブジェクトを作成している。

ポイント
actions/github-script@v7を使用してプルリク取得メソッドを実行。getは特定のPRの詳細を取得。
ownerとrepoでオーナー名とリポジトリ名でAPI操作するリポジトリを指定し、pull_numberでPRを特定する。

            if (!pr.draft) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                body: `⚠️ **注意**: このPRはまだレビュー準備が完了していない可能性があります。\nTerraformのチェック結果を確認してください。`
              });
            }
        continue-on-error: true
  • if (!pr.draft)await github.rest.pulls.getで取得した情報のなかにdraftプロパティがあって、その値がfalseかの条件式。
    • 『!』は否定演算子。
      • 省略せずに書くとif (pr.draft === false)となる。
        • draftじゃない場合その後の処理を行うと言う意味。
  • await:非同期処理の待機。
    • JavaScriptの構文。
  • github.rest.issues.createComment({:Octokitクライアントの呼び出しからrestのissuesのcreateComment関数を指定。
    • コメントを作成する。
      • オーナー名、リポジトリ名、PRナンバーを指定する。

REST APIとは
HTTPを使ってリソースを操作するもの。
URLで識別し、HTTPメソッドで操作する、
GET取得、POST作成、PATCH/PUT更新、DELETE削除
ステートレスでサーバーはクライアント情報を保持しないので、毎回リクエストに必要情報を入れる。

  • body:はコメントの内容
  • \n: 改行の意味
  • continue-on-error: true:エラーでもワークフローを継続する。
    • GitHub Actionsの機能。

ポイント
PRのdraftがあるかを確認し、なければactions/github-script@v7でPRに警告のコメントを出す。

上記の処理1ブロックの要約
まず1ステップ上の処理で、PRが存在するかを確認していてそのPRが存在するなら処理を開始するif文が最初にある。
処理内容はPRのナンバーを文字列で出力されてるのを整数の値に変換し、prNumber変数に格納し定数とする。
その後そのprNumberを使用し、actions/github-script@v7の機能で特定のPRの詳細を取得し、その中のdraftがfalseとなっていたら、コメント作成する。
そのコメント作成もactions/github-script@v7の中の関数を使用する。

      # ラベル確保
      - name: Ensure Labels Exist
        if: steps.check-pr.outputs.result == ''
        uses: actions/github-script@v7
        with:
          script: |
            async function ensureLabel(name, color = "ededed", description = "") {
              try {
                await github.rest.issues.getLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: name
                });
              } catch (error) {
                if (error.status === 404) {
                  await github.rest.issues.createLabel({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    name: name,
                    color: color,
                    description: description
                  });
                  console.log(`Created label '${name}'`);
                }
              }
            }
            
            await ensureLabel('terraform', '7B3F00', 'Terraform related changes');
            await ensureLabel('draft', 'FEF2C0', 'Draft PR - work in progress');
  • if: steps.check-pr.outputs.result == '':PRが無い時は処理を行う。
  • with:usesの渡すパラーメータの指定場所を宣言するようなもの。
  • script: |:パラメータに複数のコードを渡しますよと言う意味。
    • 『|』はJavaScriptで複数行渡すのに便利。
            async function ensureLabel(name, color = "ededed", description = "") {
              try {
                await github.rest.issues.getLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: name
                });
  • async:asyncをfunctionの前に宣言することで、非同期関数の処理が可能となり関数の中でawaitを使えるようになる。
  • function:関数の宣言。再利用可能な処理のまとまりを定義する。
  • ensureLabel:は関数の名前。
  • (name, color = "ededed", description = ""):引数の指定。
    • この関数はまだ定義している段階で、この関数が使われる時に渡して欲しい値を指定している。
  • try:このtry{}中で処理がエラーになっても止まらずに、catchで処理する構文。
  • await:非同期処理の宣言。レスポンスが返ってくるまで待つ。
  • github.rest.issues.getLabel:actions/github-script@v7に組み込まれているライブラリを使ってGitHub APIを使って呼び出している。
    • restのissuesのgetLabel、ラベルを取得するメソッド。
  • name:nameは引数の指定から持ってくるもの。
    • なので、この関数定義の段階ではnameの内容は分かっていない。

ポイント
asyncで非同期関数として非同期処理を使えるようにして、functionで関数の定義を行う。
この段階は定義の段階なので、実際の処理がある訳ではない。
関数の引数も指定して実際の処理がある場合はその引数を値として渡してもらう。
tryは処理を実行してエラーが出てもcatchに処理が移る構文。
そのtryの処理は指定したラベルを取得する処理内容となる。

              } catch (error) {
                if (error.status === 404) {
                  await github.rest.issues.createLabel({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    name: name,
                    color: color,
                    description: description
                  });
                  console.log(`Created label '${name}'`);
                }
              }
            }
            
            await ensureLabel('terraform', '7B3F00', 'Terraform related changes');
            await ensureLabel('draft', 'FEF2C0', 'Draft PR - work in progress');
  • catch (error):tryでエラーとなった時に処理がcatchに移る。
    • (error)はこの中にエラー内容が格納される。
  • if (error.status === 404) {:ifは条件分岐構文。errorの中のプロパティstatusが404の時は真。
    • ===は厳密等価演算子。
      • 数値型の404と合っているか。
        • 型も値も同じでないと真とならない。
          • ==だと変換して比較されるので意図してないことが起きてしまう可能性がある。
  • await github.rest.issues.createLabel:ラベルを作成するメソッド。
    • colorは必須のパラメータ。
  • color:ensureLabel関数に指定されている引数。
    • ラベル作成には必須。
  • description:説明文と言う意味。
  • console.log(`Created label '${name}'`);:現在どうなっているかの実行ログを残すためのもの。
    • GitHub Actionsのログとして表示される。
  • await ensureLabel('terraform', '7B3F00', 'Terraform related changes');:この文の上のは関数の定義で、ここでensureLabel関数を呼び出して使用している。
    • その引数として渡す値が()内に記載されている

ポイント
try構文でエラーになったときに処理が移る、catch構文でまずは非同期関数を定義してる。
定義してる段階で処理が始まる訳ではない。
その関数の内容はgetLabelで指定のラベルを取得して、そのラベルがあれば取得して終わり。
ラベルがなければエラーになり、catch構文に処理が移る。
その内容はエラーのステータスコードが404なら無いってことなので、処理が開始される。
createLabelでラベルを作成し、console.logでログを出力する。
と、言う処理を関数として定義していてawaut ensureLabelで関数が実行される。その際に引数を指定していて、terraform,色コード,説明文、draft,色コード,説明文となっていてそれぞれterraformとdraftのラベルを作成する。

上記の処理1ブロックの要約
まずPRがあるなら全体の処理が始まるif文からスタートしていて、内容は関数を定義し、その後にその関数を実行する内容になっている。
PRがないなら引数指定のラベルを取得し、ラベルがないなら引数指定のラベルを作成する処理となっている。

      # 新規PRの作成(既存PRがない場合のみ)
      - name: Create Draft PR
        if: steps.check-pr.outputs.result == ''
        id: create-pr
        uses: actions/github-script@v7
        with:
          script: |
            const branch = context.ref.replace('refs/heads/', '');
            const featureName = branch.replace('feature/', '').replace(/-/g, ' ');
            
            try {
              const { data: pr } = await github.rest.pulls.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: `feat: ${featureName}`,
                head: branch,
                base: 'main',
                body: `## 📝 概要
            
            ### 目的
            <!-- この変更が必要な理由を記載 -->
            
            ### 変更内容
            <!-- 何を変更したかを記載 -->
            
            ### 対象環境
            - [ ] dev
            - [ ] stg
            - [ ] prd
            
            ### チェックリスト
            - [ ] terraform fmt 実行済み
            - [ ] terraform validate 成功
            - [ ] terraform plan 確認済み
            - [ ] 必要なドキュメント更新済み
            
            ### テスト結果
            <!-- terraform planの結果やテスト内容を記載 -->
            
            ### ロールバック手順
            <!-- 問題発生時の対処法を記載 -->
            
            ---
            ${{ steps.summary.outputs.summary }}
            
            ---
            ⚠️ **このPRは自動生成されたドラフトです。内容を確認・更新してください。**`,
                draft: true
              });
              
              // ラベル追加(既に存在確認済み)
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                labels: ['terraform', 'draft']
              });
              
              // 作成者をアサイン
              await github.rest.issues.addAssignees({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                assignees: [context.actor]
              });
              
              console.log(`✅ Created Draft PR #${pr.number}`);
              return pr.number;
              
            } catch (error) {
              console.error('Failed to create PR:', error);
              throw error;
            }
          result-encoding: string
  • Pull request 用 REST API エンドポイント

  • if: steps.check-pr.outputs.result == '':if文。ステップのcheck-prでの返り値でPRが空文字で返ってれば処理を実施する。

  • id: create-pr:そのステップに付ける参照用の識別子。

    • 後続のステップやジョブでこのステップの出力値とか実行結果を参照するために使う。
  • with:usesに渡す入力パラメータを指定する

  • script: |:withで指定しているscriptパラメータ。

    • その中のJavaScriptコードを引数としてactions/github-script@v7で実行される。
      • |は、そのコードを改行を保持する複数行として渡す。
        • actions/github-script@v7はwith.scriptに渡された文字列をNode.jsでそのまま実行する。
  • const branch = context.ref.replace('refs/heads/', '');:このワークフローを起動したGitの参照元のパスのrefs/heads/を空文字に置き換えしたものをbranch変数に格納し、定数とする。

const featureName = branch.replace('feature/', '').replace(/-/g, ' ');
  • replace()はメソッド:メソッドは、オブジェクトに属する関数。
  • オブジェクトに属する関数:今回属するオブジェクトは文字列オブジェクト。
    • 文字列は実はオブジェクトで、そこに属する関数を使えて、その関数をメソッドと言う。
  • メソッドの間にある『.』:前のメソッドの返り値に対して次のメソッドを呼び出せる。
    • メソッドチェーンと言う連続処理が可能となる。
  • branch:今回のここで言うbranchは変数名、その中にはブランチ名が入ってる。
  • branch.replace('feature/', ''):そのブランチに対して最初のfeature/を空文字に置き換える。
  • .replace(/-/g, ' ');:『.』でメソッドチェーンとなって、replace('feature/', '')の返り値にreplace処理が入って『-』や『g』を空文字置き換える

ポイント
変数名branch内のfeature/と『-』『g』を空文字に置き換えてfeatureName変数に格納して定数宣言する。

  • { data: pr }:createのAPIレスポンスのデータプロパティを変数prに代入している。
  • await github.rest.pulls.create:非同期処理を待って、PR作成を行う。
  • github:GitHub APIクライアントオブジェクト。
  • rest:REST APIプロパティ。
  • pulls:PR関連の機能のグループ。
  • create:PR作成メソッド。
  • github.rest.pulls.createの必須プロパティ:owner,repo,head,base
  • title: `feat: ${featureName}`,:作成するPRのタイトルを指定。
    • featureName変数を展開。
  • body:PR本文を指定。
  • ${{ steps.summary.outputs.summary }}:ステップsummaryで$GITHUB_OUTPUTに格納してるのでoutputsとして参照出来る。
  • draft: true:PRをドラフト状態で作成する。
  • await github.rest.issues.addLabelsactions/github-script@v7でラベル追加処理が出来る。
  • github.rest.issues.addAssignees:Issueに担当者をアサイン。
  • assignees: [context.actor]:contextのプロパティactorはGitHub Actionsを実行したユーザー。
    • そのユーザーをIssueの担当者に指定。
  • return pr.number:returnするとscriptの処理がそこで終わり、出力resultに入って他のステップから参照が可能になる。
  • catch (error):tryの処理がエラーになると処理がcatchへ受け渡しされる。
  • console.error('Failed to create PR:', error);:エラーログへエラー内容を文字列と一緒に出力させる。
  • result-encoding: string:returnするとresultに入って他ステップで参照可能になるけど、その型式を指定している。
    • stringは文字列。
  • throw error:この時点で処理を外に投げる。
    • 外にcatchがあれば処理は続くし、なければactions/github-script@v7に関数の戻り値がrejectとなってステップ失敗となる。

上記の処理1ブロックの要約
ifでPRがない場合に処理がスタートする。
ワークフロー実行したGit参照のパスからrefs/heads/を空文字にしたパスをbranchへ格納し、その後feature/や『-』『g』も空文字に置き換えてfeatureName変数へ格納する。
その後PRを作成し、ラベルを追加し担当者を設定する。

      # 既存PRにコメント追加(既存PRがある場合)
      - name: Comment on existing PR
        if: steps.check-pr.outputs.result != ''
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = parseInt('${{ steps.check-pr.outputs.result }}');
            
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: `### 🔄 新しいコミットがプッシュされました
            
            **Commit:** \`${context.sha.substring(0, 7)}\`
            **Author:** @${context.actor}
            **Branch:** \`${context.ref.replace('refs/heads/', '')}\`
            
            ${{ steps.summary.outputs.summary }}
            
            **詳細:** [Actions Run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
            });
            
            console.log(`✅ Added comment to PR #${prNumber}`);
  • if: steps.check-pr.outputs.result != '':if文でcheck-prステップでPRが存在するかのoutputsで!=なので、空文字では無いのが真。
    • つまりPRがある状態の時に処理が開始される。
  • const prNumber = parseInt('${{ steps.check-pr.outputs.result }}');:PRのナンバーを数値型に変換し、変数prNumberに格納し定数と宣言する。
  • await:APIのレスポンスが返って来るまで待つ非同期処理の完了を待つ演算子。
  • github.rest.issues.createComment:オブジェクトなのでgithubの中のrestの中のissuesの中のcreateComment関数を実行出来る。
    • owner``repo``bodyは必須。
  • github:actions/github-script@v7が用意する認証済みOctokit。
    • 変数でオブジェクト。
  • rest:プロパティで中身はオブジェクト。
    • REST系の入り口。
  • issues:名前空間としてのプロパティ。
    • 中身はオブジェクト。
  • createComment:メソッド。
    • 関数プロパティ。
      • この関数を実行する。
**Commit:** \`${context.sha.substring(0, 7)}\`

Contexts reference

  • context:contextはGitHub Actonsの実行時の環境情報をまとめたJavaScriptオブジェクト。
    • それをスクリプトが実行される環境で自動的にグローバル変数として提供されている。
  • context.sha:は現在のコミットのSHA。
    • そのコミットを一意に識別する40文字の16進ハッシュ値。
  • .substring(0, 7):先頭何文字目から何文字分を抽出するメソッド。
    • JavaScriptのインデックスが0から始まる。
  • バックスラッシュ:bodyが『`』で囲ってるので、出力文字の『`』はバックスラッシュで無効化する必要がある。
    • GitHubの表示上で『`』が認識される必要がある。
**詳細:** [Actions Run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`});
  • xxx:Markdownのリンク記法、今回の場合は表示テキストがActions Runとなる。
  • ${context.serverUrl}:ワークフロー実行時のコンテキストのサーバーURL。
    • 今回はhttps://github.com
  • /actions/runs/:リポジトリ内のワークフロー実行ページのパス
  • ${context.runId}:今回のワークフロー実行ID。

ポイント
これらを繋ぎ合わせるとActions Runのリンクとなる。
そこでは実行ワークフローに関しての様々な記録されてる

上記の処理1ブロックの要約
check-prでPRの値が空白行ではない場合に処理をが始まる。
PRの.numberを数値型に変換してprNumber変数へ格納。
その後コメント追加処理を実行し、コミットのSHAの最初7桁とワークフローを実行したユーザー名とリポジトリ名(今回は)をコメントに追加。summaryも追加。Actions RunのURLも追加。

      # ジョブサマリーの追加(Actions UIで見やすく)
      - name: Add Job Summary
        if: always()
        run: |
          echo "## 📊 Terraform CI Results" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "${{ steps.summary.outputs.summary }}" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          
          if [ "${{ steps.check-pr.outputs.result }}" != "" ]; then
            echo "### 📌 Pull Request" >> $GITHUB_STEP_SUMMARY
            echo "- PR: #${{ steps.check-pr.outputs.result }}" >> $GITHUB_STEP_SUMMARY
            echo "- Status: Existing PR updated with comment" >> $GITHUB_STEP_SUMMARY
          elif [ "${{ steps.create-pr.outputs.result }}" != "" ]; then
            echo "### 📌 Pull Request" >> $GITHUB_STEP_SUMMARY
            echo "- PR: #${{ steps.create-pr.outputs.result }}" >> $GITHUB_STEP_SUMMARY
            echo "- Status: New Draft PR created" >> $GITHUB_STEP_SUMMARY
          fi
          
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "### 📝 Next Steps" >> $GITHUB_STEP_SUMMARY
          echo "1. Review the check results above" >> $GITHUB_STEP_SUMMARY
          echo "2. Fix any formatting or validation issues" >> $GITHUB_STEP_SUMMARY
          echo "3. Run \`terraform plan\` locally to verify changes" >> $GITHUB_STEP_SUMMARY
          echo "4. Update PR description with details" >> $GITHUB_STEP_SUMMARY
          echo "5. Mark PR as ready for review when complete" >> $GITHUB_STEP_SUMMARY

GitHub Actions のワークフロー構文

  • if: always():GitHub Actionsの条件付き実行で使用される特殊な関数で、前のステップの成功・失敗に関わらず、必ずそのステップを実行することを指定する。
  • echo "## 📊 Terraform CI Results" >> $GITHUB_STEP_SUMMARY:文字列を$GITHUB_STEP_SUMMARY変数に格納。
  • >>:リダイレクト演算子。追記していく。>は上書きしていく。
  • echo "" >> $GITHUB_STEP_SUMMARY:空文字を追記して改行を作成
  • echo "${{ steps.summary.outputs.summary }}" >> $GITHUB_STEP_SUMMARY:summaryステップでecho "summary<<EOF" >> $GITHUB_OUTPUTのsummaryがキー名となる。
    • ステップ名がsummaryで、そのoutputsを指定してて、そのsummaryキーを指定している。
  • if [ "${{ steps.check-pr.outputs.result }}" != "" ]; then:check-prステップでの値が空文字では無い時は真。
    • つまりPRがあれば真。
      • thenは真の場合の処理が始まる構文。
  • elif:ifに対しての偽の時の処理に対して、もう一度if文を付ける時の書き方。
  • elif [ "${{ steps.create-pr.outputs.result }}" != "" ]; then:PR作成が空文字では無い場合、つまりPR作成がある場合は真。

上記の処理1ブロックの要約
if: alwaysはGitHub Actionsでの特殊関数で前のステップの成功の可否に関わらず実行されるもので、今回はSUMMARYへの文字列出力を担っている。
Resultsですよってタイトルから始まり、terrafrom fmf,validate,tflintのチェック結果の出力。
次にPRがあれば、更新しましたよの出力。PRが作成されていればPRを作成しましたよの出力。
最後に次何すれば良いのかの出力をしている。

feature push用.github/workflows/terraform-ci-featuer-lint-draftpr.ymlの要約
infra関連ファイルの更新で、fratureブランチからpushがあった場合に実行するワークフロー。
実行内容は、terraform fmt,init,validate,tflintの内容チェック。
んで、その結果をSUMMARYに表示している。
また既存のPRがあるときとないときで処理が変わってて、ある時はコメントの追加をする
既存PRがない時はPRを新たに作成して、ラベルを追加し、担当者も追加する。
それらの結果をまとめて表示する。

feature push用ワークフローのテスト実行してみる

Error: Unhandled error: HttpError: GitHub Actions is not permitted to create or approve pull requests.

PRを作成する許可がないとのこと。
コード上は許可しているが、GitHubの設定で許可されていない。
許可するのは適切ではないので、PR作成の箇所を削除。

feature push用.github/workflows/terraform-ci-featuer-lint-draftpr.ymlの修正

# ラベル確保
# 新規PRの作成(既存PRがない場合のみ)

上記を削除

      # PRがドラフトでない場合にだけ警告コメント(PR作成はしない)
      - name: Warn if PR is not Draft
        if: steps.check-pr.outputs.result != ''
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = parseInt('${{ steps.check-pr.outputs.result }}');
            const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: prNumber
            });
            if (!pr.draft) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                body: `⚠️ **注意**: このPRはまだレビュー準備が完了していない可能性があります。\nTerraformのチェック結果を確認してください。`
              });
            }
        continue-on-error: true

これを追加。

上記1ブロック要約
ifでPRが空文字ではない場合が真。つまりPRがあれば処理を実行する。
PRのナンバーを数値型に変換し、該当のナンバーのPRの詳細を取得する。
draftがノット、つまりfalseの時が真で、draftとなってないPRの時にコメントを追加する。

再度テスト実行

Error: Unhandled error: SyntaxError: Missing } in template expression

構文エラー。修正。

console.log(`Found existing PR #${prs[0].number]}`);
↓
console.log(`Found existing PR #${prs[0].number}`);

再度テスト実行

Terraform Format Check

│ Error: The specified plugin cache dir ~/.terraform.d/plugin-cache cannot be opened: stat ~/.terraform.d/plugin-cache: no such file or directory
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Error: Terraform exited with code 3.

terraformは環境変数の『~』を展開しないので文字列として認識されているので、絶対パスのように修正。

TF_PLUGIN_CACHE_DIR: ~/.terraform.d/plugin-cache
↓
TF_PLUGIN_CACHE_DIR:  /home/runner/.terraform.d/plugin-cache
~~~~~~~~~~~~~~~~~~~~~~
tano1:0603game tano$ terraform fmt -recursive infra/
# ローカルでfmtでフォーマット整えることを実行

Terraform Validate

  │ Error: The specified plugin cache dir ~/.terraform.d/plugin-cache cannot be opened: stat ~/.terraform.d/plugin-cache: no such file or directory
~~~~~~~~~~~~~~~~~~~~~~~
  │ Warning: Provider lock file not updated
  │ 
  │ Changes to the provider selections were detected, but not saved in the
  │ .terraform.lock.hcl file. To record these selections, run "terraform init"
  │ without the "-lockfile=readonly" flag.
~~~~~~~~~~~~~~~~~~~~~~~
  Error: registry.terraform.io/hashicorp/aws: the cached package for registry.terraform.io/hashicorp/aws 5.100.0 (in .terraform/providers) does not match any of the checksums recorded in the dependency lock file

~/.terraform.d/plugin-cacheがないことは上記で修正済み
現在のlock fileにはmacのチェックサムがあるだけなので、他のプラットフォームだと検証が上手くいかずエラーとなる。

$ terraform providers lock   -platform=linux_amd64   -platform=darwin_arm64   -platform=darwin_amd64   -platform=windows_amd64

上記コマンドで他のプラットホームのチェックサムを作成。

TFLint

# 他にもWarningが7件くらい
Error: Process completed with exit code 2.
terraform "required_version" attribute is required

モジュールでterraformのバージョンを指定していない。

codexで修正

Missing version constraint for provider "aws" in required_providers

プロバイダーを指定していない。

codexで修正

[Fixable] local.oac_name is declared but not used

localsにoac_nameを定義してるのに使っていない。

localsのoac_nameを削除

もう一度テストし成功

続き・・・
(2/2)CI/CD GitHub Actionsでインフラを自動デプロイ!

Discussion