GitHub Actions: деплой Terraform з review запланованих змін

Автор |  07/03/2024
 

У пості GitHub Actions: деплой Dev/Prod оточень з Terraform я вже описував те, як можна реалізувати CI/CD для Terraform з GitHub Actions, але в тому рішенні є один суттєвий недолік: немає можливості зробити review змін перед тим, як їх застосувати з terraform apply.

GitHub Actions має можливість використання Reviewing deployments для approve/reject, проте ця можливість доступна тільки на GitHub Enterprise.

Тож як ми можемо вирішити цю проблему?

Звісно, ми взагалі можемо використати рішення на кшталт Atlatis або Gaia – але коли на проекті всього 4-5 репозиторіїв з Terraform, то такі утілити будуть трохи overkill.

Як варіант – продовжити використання GitHub Actions з hashicorp/setup-terraform, але робити terraform plan під час створення Pull Request, а його output виносити в коментарі до PR. Тоді перед тим, як підтвердити Merge – ми зможемо подивитись на те, які зміни будуть застосовані, і виконувати terraform apply після того, як feature/fix branch буде вмержено в master.

Хоча це теж не ідеальне рішення – бо між створенням Pull Request і виконанням Terraform Plan та мержем і виконанням Terraform Apply може пройти час, за який в інфраструктурі щось зміниться, і той Plan вже буде не актуальний – тож треба розібратись і з цим.

Отже, що будемо сьогодні робити:

  • створимо тестовий код Terraform
  • створимо два GitHub Actions Workflow:
    • test on Pull Request created
    • deploy on Pull Request merged
  • і подивимось на варіанти того, як можна вирішити проблему з неактуальним Terraform Plan

Підготовка

Створимо проект Terraform, потім підготуємо репозиторій GitHub.

Terraform – тестові ресурси

В тестовому репозиторії створюємо каталог terraform, і в ньому файл terraform.tf:

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-test"
    key            = "atlas-test.tfstate"
    region         = "us-east-1"
    encrypt        = true
  }
}

terraform {
  required_version = "~> 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14"
    }
  }
}

provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      component  = "devops"
      created-by = "terraform"
      environment = "test"
      project     = "atlas-test"
    }
  }
}

Для тесту будемо створювати S3-бакет – додаємо main.tf:

resource "aws_s3_bucket" "example" {
  bucket = "atlas-test-bucket-ololo"

  tags = {
    Name = "atlas-test-bucket-ololo"
  }
}

Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket – AuthorizationHeaderMalformed

Трохи забігаючи наперед, і трохи “оффтопу”: вже коли робив terraform apply, то зловив помилку:

Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket, https response error StatusCode: 400, api error AuthorizationHeaderMalformed: The authorization header is malformed; the region ‘us-east-1’ is wrong; expecting ‘us-west-2’

Текст трохи вводить в оману, бо – при чому тут регіони? В provider "aws" у нас явно задано us-east-1, з AIM Role теж в порядку. Звідки взагалі береться us-west-2?

Якщо включити debug з export TF_LOG=debug, то бачимо, що дійсно:

http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-west-2″.

А “лікується” це тим, що – як ми знаємо – ім’я корзини має ж бути унікальним на весь AWS Region, тобто якщо додати отой суфікс “ololo” – то все працює, як треба:

http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-east-1

Схоже, що чи то сам AWS, чи Terraform намагається зробити корзину в іншому регіоні, якщо в заданому така вже у когось є.

Окей, йдемо далі.

Робимо cd terraform/ && terraform fmt && terraform init && terraform plan:

Тут все готово, можемо переходити до налаштувань GitHub Actions.

GitHub Actions – змінні оточення репозиторію

Для запуску Terraform в GitHub Actions нам потрібна одна змінна, в якій ми будемо передавати AWS IAM Role, за допомогою якої GitHub зможе виконувати дії в нашому AWS акаунті – див. Configuring OpenID Connect in Amazon Web Services.

Переходимо в Settings > Actions secrets and variables, переходимо в Variables:

В Repository variables додаємо нову змінну TF_AWS_ROLE – вона буде використовуватись в aws-actions/configure-aws-credentials:

І в принципі наразі це все, що нам потрібно – можемо починати створювати наші GitHub Actions Workflow.

GitHub Actions – створення Workflow

Для того, щоб Actions зміг запустити наш код Terrform, нам потрібно:

  • виконати checkout коду на GitHub Runner, на якому буде запускатись флоу: використаємо actions/checkout
  • виконати аутентифікацію в AWS: використаємо aws-actions/configure-aws-credentials – він виконає AssumeRole зі змінної TF_AWS_ROLE та створить змінні оточення з ключами та AWS_SESSION_TOKEN
  • запустити terraform init та інші: використаємо hashicorp/setup-terraform – він додасть сам Terraform

Тут в принципі можна використати той же підхід з окремими Actions для кожного step, як описувалось в Створення test-on-push Workflow, але зараз для наочності та простоти все опишемо прям у файлі workflow.

Workflow з Terraform Validate та Plan

Першим створимо Workflow, який буде запускатись при створенні PR та буде виконувати перевірки з terraform validate, tflint і т.д, а потім буде виконувати terraform plan, результат якого буде додавати в коментарі до Pull Request, який тригернув цей workflow.

В корні репозиторію створюємо каталог .github/workflows, і в ньому файл terraform-test-on-pr.yml:

name: "Terraform: test on PR open"

# set other jobs with the same 'group' in a queue
concurrency:
  group: deploy-test
  cancel-in-progress: false

on: 
  # can be ran manually
  workflow_dispatch:
  # run on pull-requests
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
    # only if changes were in the 'terraform' directory
    paths:
      - terraform/**
      
permissions:
  # create OpenID Connect (OIDC) ID token
  id-token: write
  # allow read repository's content by steps
  contents: read
  # allow adding comments in a Pull Request
  pull-requests: write

jobs:
  
  terraform:

    name: "Test: Terraform"
    runs-on: ubuntu-latest
    # to avoid using GitHub Runners time
    timeout-minutes: 10
    defaults:
      run:
        # run all steps in the 'terraform' directory
        working-directory: ./terraform

    steps:

      # get repository's content
      - name: "Setup: checkout"
        uses: actions/checkout@v4

      # setup 'env.AWS_*' variables to run Terraform
      - name: "Setup: Configure AWS credentials"
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_AWS_ROLE }}
          role-session-name: github-actions-terraform
          role-duration-seconds: 900
          aws-region: us-east-1

      # terraform formatting check
      - name: "Test: Terraform fmt"
        id: fmt
        run: terraform fmt -check -no-color
        # do not throw error, just warn
        continue-on-error: true

      # use TFLint to check the code
      - name: "Setup: TFLint"
        uses: terraform-linters/setup-tflint@v3
        with:
          tflint_version: v0.48.0

      - name: "Test: Terraform linter"
        run: tflint -f compact
        shell: bash

      # use official Action
      - name: "Setup: Terraform"
        uses: hashicorp/setup-terraform@v3

      # get modules, configure backend
      - name: "Test: Terraform Init"
        id: init
        run: terraform init -no-color

      # verify whether a configuration is syntactically valid
      - name: "Test: Terraform Validate"
        id: validate
        run: terraform validate -no-color

      # create a Plan to see what will be changed
      - name: "Test: Terraform Plan"
        id: plan
        run: terraform plan -no-color

В concurrency забороняємо одночасний запуск кількох workflows, див. Using concurrency, і це нам знадобиться далі, коли будемо робити terraform apply.

Для команд Terraform задаємо параметр -no-color, щоб потім в коментарях до Pull Request не мати зайвих символів:

Додаємо .gitignore:

**/.terraform/*

Див. повний приклад в Terraform.gitignore.

Комітимо всі зміни, пушимо в репозиторій, якщо зміни робили в окремому бранчі – то мержимо в master, щоб GitHub Actions “побачив” новий Workflow файл, і маємо запущений Workflow:

Додавання Terraform Plan в коментарі до Pull request

Далі використаємо actions/github-script, який через GitHub API може додавати коментарі до нашого PR.

А в коментарі використаємо outputs з нашого steps.plan.

Додаємо ще один step:

...
      # generate comment to the Pull Request using 'steps.plan.outputs'
      - name: "Misc: Post Terraform summary to PR"
        uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            <details><summary>Validation Output</summary>
      
            \`\`\`\n
            ${{ steps.validate.outputs.stdout }}
            \`\`\`
      
            </details>
      
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
      
            <details><summary>Show Plan</summary>
      
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
      
            </details>
      
            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
      
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

Тут:

  • виконуємо step якщо github.event_name == 'pull_request'
  • створюємо змінну оточення PLAN зі змістом із steps.plan.outputs.stdout
  • запускаємо actions/github-script, якому:
    • передаємо GITHUB_TOKEN для аутентифікації (використає permissions.pull-requests: write)
    • передаємо аргумент script, в якому:
      • створюємо const output, де ми формуємо текст для коментаря
      • викликаємо функцію github.rest.issues.createComment, яка:

Мене трохи засмутило, що викликається github.rest.issues.createComment – бо чому issues, коли ми маємо справу з Pull Request?

Але в документації Sending requests to the GitHub API сказано, що “issues and PRs are treated as one concept by the Octokit client“.

І в документації самого клієнта:

You can use the REST API to create comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request.

При потребі, можна отримати всі дані з контекстів так:

...
      - name: Dump Job Context
        env: 
          JOB_CONTEXT: ${{toJson(github)}}
        run: echo "$JOB_CONTEXT"

      - name: View context attributes
        uses: actions/github-script@v7
        with:
          script: console.log(context)  
...

Окей.

Пушимо зміни, чекаємо, поки виконається джоба (пам’ятаємо, що тригер у нас – зміни в каталозі terraform):

І маємо новий коментар в Pull Request:

Що тут ще можна “затюнити” – це замість створення нового коментаря в Pull Request оновлювати вже існуючий.

Для цього можна використати функцію github.rest.issues.listComments, за допомогою якої знайти всі коментарі comment.body.includes(‘Terraform Format and Style’), і якщо такий знайдеться – то виконати github.rest.issues.updateComment, а якщо ні – то github.rest.issues.createComment, як робили вище:

...
      - name: "Misc: Post Terraform summary to PR"
        uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // 1. Retrieve existing bot comments for the PR
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            })
            const botComment = comments.find(comment => {
              return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
            })
      
            // 2. Prepare format of the comment
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            <details><summary>Validation Output</summary>
      
            \`\`\`\n
            ${{ steps.validate.outputs.stdout }}
            \`\`\`
      
            </details>
      
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
      
            <details><summary>Show Plan</summary>
      
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
      
            </details>
      
            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
      
            // 3. If we have a comment, update it, otherwise create a new one
            if (botComment) {
              github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: output
              })
            } else {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: output
              })
            }

Хоча тут кому-як, бо може більш інформативно буде мати історію планів.

Окей.

Тепер ми маємо GitHub Actions Workflow, який виконує перевірки Terraform, і постить результат Plan в коментарі до Pull Request.

Єдине, що ще треба на увазі – це ліміт на кількість символів в коментарі до Pull Request – 65535 – і деякі Plan можуть не влізти.

Останнім нам треба додати Workflow, який буде виконувати Terraform Apply, коли PR змержено.

Workflow з Terraform Apply

Створюємо файл terraform-deploy-on-pr-merge.yml:

name: "Terraform: Apply on push to master"

# set other jobs with the same 'group' in a queue
concurrency:
  group: deploy-test
  cancel-in-progress: false

on:
  # run on PR merge, e.g. 'push' changes to the 'master'
  push:
    branches:
      - master
    # only if changes were in the 'terraform' directory
    paths:
      - terraform/**

permissions:
  # create OpenID Connect (OIDC) ID token
  id-token: write
  # allow read repository's content by steps
  contents: read

jobs:

  deploy:

    name: "Deploy: Terraform"
    runs-on: ubuntu-latest
    # to avoid using GitHub Runners time
    timeout-minutes: 30
    defaults:
      run:
        # run all steps in the 'terraform' directory
        working-directory: ./terraform

    steps:

      # get repository's content
      - name: "Setup: checkout"
        uses: actions/checkout@v4

      # setup 'env.AWS_*' variables to run Terraform
      - name: "Setup: Configure AWS credentials"
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_AWS_ROLE }}
          role-session-name: github-actions-terraform
          role-duration-seconds: 900
          aws-region: us-east-1

      # use TFLint to check the code
      - name: "Setup: TFLint"
        uses: terraform-linters/setup-tflint@v3
        with:
          tflint_version: v0.48.0
          
      - name: "Test: Terraform lint"
        run: tflint -f compact
        shell: bash

      # use official Action
      - name: "Setup: Terraform"
        uses: hashicorp/setup-terraform@v3

      # get modules, configure backend
      - name: "Setup: Terraform Init"
        id: init
        run: terraform init -no-color

      # verify whether a configuration is syntactically valid
      - name: "Test: Terraform Validate"
        id: validate
        run: terraform validate -no-color

      # create a Plan to use with the 'apply'
      - name: "Deploy: Terraform Plan"
        id: plan
        run: terraform plan -no-color -out tf.plan

      # deploy changes using Plan's file
      - name: "Deploy: Terraform Apply"
        id: apply
        run: terraform apply -no-color tf.plan

Тут все майже те саме, що і попередньому workflow, тільки тригером буде push в master, а результат виконання terraform plan ми зберігаємо у файл tf.plan, і з нього ж потім виконуємо terraform apply.

Пушимо в репозиторій, мержимо – і маємо виконаний apply:

Добре. Виглядає начебто чудово?

Але є проблема.

What if? Outdated Terraform Plan

А проблема полягає в тому, що за той час, коли ми подивились результат terraform plan в коментарях до PR і до моменту, коли він буде змержений і виконаний – пройде якийсь час, за який в ресурсах можливі зміни – чи то хтось запустить deploy-джобу з іншого PR, чи хтось задеплоїть власні зміни зі своєї машини (у нас це поки що допускається).

І тоді той Plan, який ми бачили в результатах виконання workflow Terraform Test вже буде неактуальний.

Друге питання – що в terraform apply виконується не той Plan, який ми бачили в коментарях і який ми апрувнули, а новий, який генерується вже під час виконання деплою.

Тож що ми можемо зробити, щоб запобігти такому?

Terraform: “Saved plan is stale”

Як варіант – це зберігати результати виконання terraform plan в файл, і потім цей же файл передавати в terraform apply.

Тоді, якщо в Terraform State відбулися зміни, то файл з Plan, який ми передамо на apply, сфейлиться, див. Manual State Pull/Push.

Як можемо перевірити те, як воно працює (тобто фейлиться):

  1. деплоїмо нашу корзину
  2. додаємо тег
  3. робимо terraform plan -out test.plan
  4. додаємо ще один тег
  5. робимо terraform apply
  6. а потім ще раз terraform apply, але вже з файлу test.plan – симулюємо зміни, які відбулися між запуском наших workflow

Отже – маємо корзину:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
  }
}

Деплоїмо її з terraform apply:

$ terraform apply
...
aws_s3_bucket.example: Creating...
aws_s3_bucket.example: Creation complete after 3s [id=atlas-test-bucket-ololo]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Додаємо тег:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
    NewTag = "NewTag"
  }
}

Виконуємо terraform plan, результат зберігаємо у файл:

$ terraform plan -out=test.plan
...
Plan: 0 to add, 1 to change, 0 to destroy.

Saved the plan to: test.plan

Додаємо ще один тег:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
    NewTag = "NewTag"
    NewTag2 = "NewTag2"
  }
}

Деплоїмо без test.plan:

$ terraform apply
...
aws_s3_bucket.example: Modifying... [id=atlas-test-bucket-ololo]
aws_s3_bucket.example: Modifications complete after 3s [id=atlas-test-bucket-ololo]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

А тепер пробуємо задеплоїти з файлу:

$ terraform apply test.plan
Acquiring state lock. This may take a few moments...
╷
│ Error: Saved plan is stale
│ 
│ The given plan file can no longer be applied because the state was changed by another operation after the plan was created.

Чудово – наш деплой обламався.

GitHub Actions та Artifacts: передача файлу з Terraform Plan

Тепер спробуємо реалізувати механізм передачі plan-файлу між workflow. Спочатку зробимо його збереження в artifact.

Note: тут ще треба враховувати питання Security, бо в plan можуть бути конфіденційні дані. З іншого боку, якщо хтось отримав доступ до вашого CI – то ви й так маєте проблеми.

Save Plan output as an Artifact

Тож що нам треба зробити:

  1. при створенні Pull Request виконуємо terraform plan -out name.tfplan
  2. зберігаємо файл name.tfplan як артефакт
  3. при мержі Pull Request завантажуємо цей name.tfplan на GitHub Runner
  4. і виконуємо terraform apply name.tfplan

Виглядає наче досить просто? Якби ж то…

Почнемо з вигрузки артефакту після тесту – тут все дійсно просто.

Задля того, щоб при виконанні terraform apply взяти артефакт саме з цього PR – додамо в ім’я файлу номер PR.

Оновлюємо файл terraform-test-on-pr.yml:

...
      # create a Plan to see what will be changed
      # save it to the file with a PR number
      - name: "Test: Terraform Plan"
        id: plan
        run: terraform plan -no-color -out env-test-${{ github.event.pull_request.number }}.tfplan

      # save as an artifact to this workflow run
      - name: Upload Terraform Plan
        uses: actions/upload-artifact@v4
        with:
          name: env-test-${{ github.event.pull_request.number }}.tfplan
          path: "terraform/env-test-${{ github.event.pull_request.number }}.tfplan"
          # throw an error if we can't find the Plan's file
          if-no-files-found: error
          # replace if an existing one is found
          overwrite: true
...

(задаємо тут в path каталог terraform/, бо “upload-artifact action does not use the working-directory setting”, див. No files were found with the provided path: build. No artifacts will be uploaded.)

Пушимо, і перевіряємо джобу:

Use Terraform Plan’s Artifact for the Terraform Apply

Далі, нам треба цей файл використати в іншому workflow, де відбувається деплой – і тут маємо дві проблеми:

  1. офіційний actions/download-artifact не підтримує завантаження файлів з інших workflow
  2. наш workflow з terraform apply запускається при івенті push, а не pull_request – і в контексті github у нас вже нема github.event.pull_request.number

Першу проблеми ми можемо вирішити за допомогою іншого Action – dawidd6/action-download-artifact, якому можна передати ім’я файлу іншого workflow, в якому буде потрібний артефакт.

А другу проблему можемо вирішити за допомогою Action jwalton/gh-find-current-pr, який виконує запит до GitHub API, і повертає номер PR, з якого був зроблений Merge.

Отже, оновлюємо наш terraform-deploy-on-pr-merge.yml – додаємо permissions.actions: read, permissions.pull-requests: read та два нових step – прибираємо Terraform Plan, отримуємо PR number, та оновлюємо Terraform Apply – передаємо файл з планом:

...
permissions:
  # create OpenID Connect (OIDC) ID token
  id-token: write
  # allow read repository's content by steps
  contents: read
  # get PR number
  pull-requests: read
  # allow download artifacts
  actions: read
...
      # get a PR number used to make the 'push' when merging
      - name: "Misc: get PR number"
        uses: jwalton/gh-find-current-pr@master
        id: findpr
        with:
          state: all

      # download artifact witjh the Terraform Plan file of the 'Terraform Test' workflow         
      - name: "Misc: Download Terraform Plan"
        uses: dawidd6/action-download-artifact@v3
        with:
          github_token: ${{secrets.GITHUB_TOKEN}}
          # the Workflow to look for the artifact
          workflow: terraform-test-on-pr.yml
          # PR number used to generate the artifact and trigger this workflow
          pr: ${{ steps.findpr.outputs.pr }}
          # artifact's name
          name: env-test-${{ steps.findpr.outputs.pr }}.tfplan
          path: terraform/
          # ensure we have the file in the workflow
          check_artifacts: true

      - name: "Deploy: Terraform Apply"
        id: apply
        run: terraform apply -no-color env-test-${{ steps.findpr.outputs.pr }}.tfplan

Все пушимо, мержимо – і маємо виконаний Terraform Apply з використанням файлу з Terraform Plan попереднього workflow:

Також ще один важливий нюанс – завдяки однаковому значенню в concurrency.group в обох Workflow – наш деплой завжди буде чекати виконання Plan, що корисно, коли PR створюється і тут же мержиться:

...
# set other jobs with the same 'group' in a queue
concurrency:
  group: deploy-test
  cancel-in-progress: false
...

Рішення виглядає цілком робочим, і перевірки з кількома одночасними PR пройшло. Хоча мене трохи напрягає те, що тут маємо “too many moving parts”, ще й покладаємось на thirdparty GitHub Actions.

Використання GitHub Branch protection rule

Є ще один варіант, і можна або використати тільки його – або варіант, писаний вище + цей: це задати Branch protection rule, де буде вимога в бранчі, з якого робиться PR, мати всі зміни, які вже є в master.

Якщо використовувати тільки такий підхід – то прибираємо steps з uploads/download артефакту, і натомість використовуємо terraform plan -out file.tfplan та terraform apply file.tfplan в одній джобі, як робили до рішення з artifacts.

Хоча при такому підході ви все одно покладаєтесь на те, що Plan, який буде створено під час виконання джоби буде == тому плану, який ви дивились в коментарях, бо “запобіжник” з “Saved plan is stale” тут не спрацює.

Втім, налаштувати Branch protection rule все одно буде корисно:

Включаємо “Require status checks to pass before merging” та “Require branches to be up to date before merging“:

Тоді, якщо в master бранч був зроблений merge з іншого бранчу – GitHub не дозволить виконати PR Merge, поки ви не оновите свій бранч, а це викличе ще один запуск workflow з Terraform Test і генерацію нового Plan, який буде додано в коментарі:

Клікаємо Update branch – запускається нова перевірка, генерується новий артефакт з новим Plan в артефакті (якщо комбінуємо обидва рішення):

І тепер можемо мержити, і деплоїти:

Готово.

P.S. І в будь-якому разі завжди майте S3 Bucket Versioning, щоб мати бекап ваших state-файлів.