如何在GitHub Actions中实现Terraform计划免审、应用需审并PR展示计划
我正在编写一个用Terraform将基础设施部署到Azure的GitHub Actions工作流,原本通过GitHub环境完成各环境的认证,每个环境存放部署所需的变量和密钥。
当前的问题是:现有terraform作业会同时执行计划(plan)和应用(apply),开发人员无法提前查看生成的计划。我想要实现计划自动运行,仅在应用阶段等待审批,但直接用GitHub环境会导致每个环境生成两次审批步骤;同时需要在Terraform文件变更时,自动在PR评论中展示计划。
我想到的临时方案是把所有密钥和变量放在仓库级别,只将GitHub环境用作审批闸门。请问如何实现这个需求?有没有更优的解决思路?
我已将计划和应用拆分为两个独立作业,但被迫使用PAT而非工作流自带的GitHub密钥——因为默认密钥没有读取环境变量的权限。
计划作业中新增了读取环境变量的步骤:
- name: Set environment context id: context env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} run: | vars=$(gh variable list --env ${{ matrix.env }}) echo "$vars" | while read -r line; do if [[ -n $line ]]; then var_name=$(echo $line | awk '{print $1}') var_value=$(echo $line | awk '{print $2}') echo "Setting environment variable $var_name=$var_value" echo "$var_name=$var_value" >> $GITHUB_ENV fi done
计划完成后,会保存制品供应用作业使用。
dev环境

prd环境

name: Terraform on: pull_request: branches: - main paths: - "**.tf" - .github/workflows/deploy_infrastructure.yml workflow_dispatch: inputs: env: description: Environment to deploy to type: environment permissions: id-token: write # Required for Azure login using federated credentials contents: read # Required for the checkout of code pull-requests: write # Required to write a PR comment jobs: deployment-matrix: name: Set deployment matrix runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - id: set-matrix shell: bash run: | includes=() if [ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]; then includes+=('{"env": "${{ inputs.env }}"}') else includes+=('{"env": "dev"}') if [ "$GITHUB_REF_NAME" == "main" ]; then includes+=('{"env": "prd"}') fi fi includes_string=$(IFS=,; echo "${includes[*]}") deployments="{\"include\": [$includes_string]}" echo "Generated strategy matrix: $deployments" echo "matrix=${deployments}" >> $GITHUB_OUTPUT terraform: name: Terraform runs-on: ubuntu-latest needs: - deployment-matrix strategy: matrix: ${{fromJson(needs.deployment-matrix.outputs.matrix)}} max-parallel: 1 environment: ${{ matrix.env }} defaults: run: working-directory: terraform steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Set up Terraform uses: hashicorp/setup-terraform@v2 - name: Azure Login uses: azure/login@v1 with: client-id: ${{ secrets.ARM_CLIENT_ID }} tenant-id: ${{ secrets.ARM_TENANT_ID }} subscription-id: ${{ secrets.ARM_SUBSCRIPTION_ID }} - name: Format id: fmt run: terraform fmt -check continue-on-error: true - name: Init id: init run: | terraform init \ -backend-config="resource_group_name=${{ vars.RESOURCE_GROUP_NAME }}" \ -backend-config="storage_account_name=${{ vars.TF_STORAGE_ACCOUNT_NAME }}" \ -backend-config="client_id=${{ secrets.ARM_CLIENT_ID }}" \ -backend-config="tenant_id=${{ secrets.ARM_TENANT_ID }}" \ -backend-config="subscription_id=${{ secrets.ARM_SUBSCRIPTION_ID }}" - name: Validate id: validate run: terraform validate - name: Plan id: plan continue-on-error: true run: | terraform plan -var="environment=${{ matrix.env }}" -no-color -out=tfplan > /dev/null terraform show -no-color tfplan - uses: actions/github-script@v6 if: github.event_name == 'pull_request' name: PR Comment with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | 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 summary') }); if (botComment) { github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, }); } const output = `# Terraform summary #### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` #### Terraform Plan 📖\`${{ steps.plan.outcome }}\` #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\` <details><summary>Show plan</summary> \`\`\`\n ${{ steps.plan.outputs.stdout }} \`\`\` </details> *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`; await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output }) - name: Apply if: steps.fmt.outcome == 'success' && steps.plan.outcome == 'success' run: terraform apply tfplan
核心思路
保留GitHub环境的变量/密钥隔离能力,拆分plan和apply作业:仅让apply作业关联GitHub环境触发审批,plan作业通过配置环境读取权限直接访问对应环境的变量,无需额外PAT。
具体实现步骤
1. 拆分作业为Plan和Apply独立任务
jobs: # 保留原有的部署矩阵生成作业 deployment-matrix: name: Set deployment matrix runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - id: set-matrix shell: bash run: | includes=() if [ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]; then includes+=('{"env": "${{ inputs.env }}"}') else includes+=('{"env": "dev"}') if [ "$GITHUB_REF_NAME" == "main" ]; then includes+=('{"env": "prd"}') fi fi includes_string=$(IFS=,; echo "${includes[*]}") deployments="{\"include\": [$includes_string]}" echo "Generated strategy matrix: $deployments" echo "matrix=${deployments}" >> $GITHUB_OUTPUT # 独立的Plan作业:自动运行,无审批 terraform-plan: name: Terraform Plan (${{ matrix.env }}) runs-on: ubuntu-latest needs: deployment-matrix strategy: matrix: ${{fromJson(needs.deployment-matrix.outputs.matrix)}} max-parallel: 1 # 配置权限:允许读取环境变量 permissions: environments: read id-token: write contents: read pull-requests: write defaults: run: working-directory: terraform steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Set up Terraform uses: hashicorp/setup-terraform@v2 - name: Azure Login uses: azure/login@v1 with: client-id: ${{ secrets.ARM_CLIENT_ID_${{ matrix.env }} }} tenant-id: ${{ secrets.ARM_TENANT_ID_${{ matrix.env }} }} subscription-id: ${{ secrets.ARM_SUBSCRIPTION_ID_${{ matrix.env }} }} - name: Format id: fmt run: terraform fmt -check continue-on-error: true - name: Init id: init run: | terraform init \ -backend-config="resource_group_name=${{ vars.RESOURCE_GROUP_NAME_${{ matrix.env }} }}" \ -backend-config="storage_account_name=${{ vars.TF_STORAGE_ACCOUNT_NAME_${{ matrix.env }} }}" \ -backend-config="client_id=${{ secrets.ARM_CLIENT_ID_${{ matrix.env }} }}" \ -backend-config="tenant_id=${{ secrets.ARM_TENANT_ID_${{ matrix.env }} }}" \ -backend-config="subscription_id=${{ secrets.ARM_SUBSCRIPTION_ID_${{ matrix.env }} }}" - name: Validate id: validate run: terraform validate - name: Plan id: plan continue-on-error: true run: | terraform plan -var="environment=${{ matrix.env }}" -no-color -out=tfplan > /dev/null terraform show -no-color tfplan - name: Upload Plan Artifact uses: actions/upload-artifact@v3 with: name: tfplan-${{ matrix.env }} path: terraform/tfplan # 保留PR评论逻辑 - uses: actions/github-script@v6 if: github.event_name == 'pull_request' name: PR Comment with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | 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 summary') }); if (botComment) { github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, }); } const output = `# Terraform summary #### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` #### Terraform Plan 📖\`${{ steps.plan.outcome }}\` #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\` <details><summary>Show plan</summary> \`\`\`\n ${{ steps.plan.outputs.stdout }} \`\`\` </details> *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`; await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output }) # 独立的Apply作业:关联环境触发审批 terraform-apply: name: Terraform Apply (${{ matrix.env }}) runs-on: ubuntu-latest needs: terraform-plan strategy: matrix: ${{fromJson(needs.deployment-matrix.outputs.matrix)}} max-parallel: 1 # 仅此作业关联环境,触发审批流程 environment: ${{ matrix.env }} defaults: run: working-directory: terraform steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Set up Terraform uses: hashicorp/setup-terraform@v2 - name: Azure Login uses: azure/login@v1 with: client-id: ${{ secrets.ARM_CLIENT_ID_${{ matrix.env }} }} tenant-id: ${{ secrets.ARM_TENANT_ID_${{ matrix.env }} }} subscription-id: ${{ secrets.ARM_SUBSCRIPTION_ID_${{ matrix.env }} }} - name: Download Plan Artifact uses: actions/download-artifact@v3 with: name: tfplan-${{ matrix.env }} path: terraform/ - name: Init id: init run: | terraform init \ -backend-config="resource_group_name=${{ vars.RESOURCE_GROUP_NAME_${{ matrix.env }} }}" \ -backend-config="storage_account_name=${{ vars.TF_STORAGE_ACCOUNT_NAME_${{ matrix.env }} }}" \ -backend-config="client_id=${{ secrets.ARM_CLIENT_ID_${{ matrix.env }} }}" \ -backend-config="tenant_id=${{ secrets.ARM_TENANT_ID_${{ matrix.env }} }}" \ -backend-config="subscription_id=${{ secrets.ARM_SUBSCRIPTION_ID_${{ matrix.env }} }}" - name: Apply run: terraform apply tfplan
2. 环境变量/密钥命名规范
给不同环境的变量/密钥添加环境后缀,实现隔离:
- dev环境:
ARM_CLIENT_ID_DEV、RESOURCE_GROUP_NAME_DEV - prd环境:
ARM_CLIENT_ID_PRD、RESOURCE_GROUP_NAME_PRD
这样Plan作业可以通过${{ secrets.ARM_CLIENT_ID_${{ matrix.env }} }}的语法动态读取对应环境的变量,无需自定义脚本。
3. 权限配置
在Plan作业的permissions块中添加environments: read,让默认的GITHUB_TOKEN拥有读取环境变量的权限,彻底替代PAT。
方案优势
- 保留GitHub环境的变量/密钥隔离能力,无需将敏感信息移至仓库级别
- 仅Apply阶段触发审批,避免重复审批流程
- 利用GitHub原生语法动态读取环境变量,无需自定义脚本
- Plan作业自动运行并在PR中展示结果,符合开发流程预期
内容的提问来源于stack exchange,提问作者jokarl

