| name: "Terraform: CI Pipelines Infrastructure" |
| |
| on: |
| pull_request: |
| paths: ["buildkite/terraform/**"] |
| push: |
| branches: ["master"] |
| paths: ["buildkite/terraform/**"] |
| |
| # go/github-security: Explicitly set minimum permissions |
| permissions: |
| contents: read # Required to check out the code |
| id-token: write # Required for Workload Identity Federation (WIF) |
| pull-requests: write # Required to post plan output as a comment |
| |
| jobs: |
| # Job 1: Detect which organization folders changed |
| detect-changes: |
| runs-on: ubuntu-latest |
| outputs: |
| orgs: ${{ steps.process-orgs.outputs.orgs }} |
| steps: |
| - name: Checkout Code |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| with: |
| fetch-depth: 0 |
| |
| - name: Get Changed Files |
| id: changed-files |
| uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45 |
| with: |
| files: buildkite/terraform/** |
| |
| - name: Extract Orgs |
| id: process-orgs |
| shell: bash |
| run: | |
| CHANGED_FILES="${{ steps.changed-files.outputs.all_changed_files }}" |
| |
| # Pull Specific Orgs -> Cut Org Name -> Sort Unique -> JSON Array |
| ORGS=$(echo "$CHANGED_FILES" | tr ' ' '\n' | \ |
| grep -E -o "^buildkite/terraform/(bazel|bazel-trusted|bazel-testing)/" | cut -d/ -f3 | sort -u | \ |
| jq -R -s -c 'split("\n") | map(select(length > 0))') |
| |
| echo "DEBUG:ORGS changed are: $ORGS" |
| echo "DEBUG:Event Name is: ${{ github.event_name }}" |
| echo "DEBUG:Author is: ${{ github.event.pull_request.author_association }}" |
| echo "orgs=${ORGS:-[]}" >> "$GITHUB_OUTPUT" |
| |
| # Job 2: Terraform Execution |
| terraform: |
| needs: detect-changes |
| # Run only if we have changed orgs AND (it's a push to master OR PR from trusted users) |
| if: needs.detect-changes.outputs.orgs != '[]' |
| runs-on: ubuntu-latest |
| |
| # Lock per organization. Cancels old runs on PRs (saves time), but queues pushes (safe apply). |
| concurrency: |
| group: ${{ github.workflow }}-${{ matrix.org }}-${{ github.ref }} |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} |
| |
| # Use environment for secret isolation and manual approvals |
| environment: ${{ matrix.org }} |
| strategy: |
| fail-fast: false # If one org fails, don't stop the others |
| matrix: |
| org: ${{ fromJSON(needs.detect-changes.outputs.orgs) }} |
| |
| defaults: |
| run: |
| working-directory: ./buildkite/terraform/${{ matrix.org }} |
| |
| steps: |
| - name: Checkout Code |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| |
| #TODO: change this back to WIP once the provider request is approved |
| - name: Authenticate to Google Cloud |
| uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 |
| with: |
| credentials_json: '${{ secrets.GCP_SA_KEY }}' |
| |
| - name: Setup Terraform |
| uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 |
| with: |
| terraform_wrapper: false |
| terraform_version: "1.9.5" |
| |
| - name: Terraform Init |
| run: terraform init |
| |
| - name: Terraform Plan |
| if: github.event_name == 'pull_request' |
| id: plan |
| env: |
| TF_VAR_buildkite_api_token: ${{ secrets.BUILDKITE_API_TOKEN }} |
| run: | |
| terraform plan -no-color -input=false -out=tfplan |
| terraform show -no-color tfplan > plan.txt |
| |
| |
| - name: Post Plan to PR |
| if: github.event_name == 'pull_request' |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| with: |
| script: | |
| const fs = require('fs'); |
| let planOutput = "Plan file not found"; |
| try { |
| planOutput = fs.readFileSync(`buildkite/terraform/${{ matrix.org }}/plan.txt`, 'utf8'); |
| } catch (e) { |
| planOutput = "Error reading plan file: " + e.message; |
| } |
| |
| // Regex to match "Plan: X to add, Y to change, Z to destroy." |
| const planRegex = /Plan:\s+(\d+)\s+to add,\s+(\d+)\s+to change,\s+(\d+)\s+to destroy\./; |
| const match = planOutput.match(planRegex); |
| |
| let summary = `#### Terraform Plan for \`${{ matrix.org }}\` 📖`; |
| |
| if (match) { |
| const [_, toAdd, toChange, toDestroy] = match; |
| summary += `\nPlan: ${toAdd} to add, ${toChange} to change, ${toDestroy} to destroy.`; |
| } else if (planOutput.includes("No changes.")) { |
| summary += `\nNo changes. Your infrastructure matches the configuration.`; |
| } |
| |
| const maxLength = 64000; |
| let truncatedPlan = planOutput; |
| if (planOutput.length > maxLength) { |
| const msg = "\n... (truncated due to length. Read full plan in 'Terraform Plan' step of GitHub Actions run) ..."; |
| truncatedPlan = planOutput.substring(0, maxLength) + msg; |
| } |
| |
| const output = `${summary}\n<details><summary>Show Plan</summary>\n\n\`\`\`terraform\n${truncatedPlan}\n\`\`\`\n\n</details>`; |
| |
| github.rest.issues.listComments({ |
| issue_number: context.issue.number, |
| owner: context.repo.owner, |
| repo: context.repo.repo |
| }).then(({ data: comments }) => { |
| const botComment = comments.find(comment => comment.body.includes(`#### Terraform Plan for \`${{ matrix.org }}\``)); |
| if (botComment) { |
| return github.rest.issues.updateComment({ |
| comment_id: botComment.id, |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| body: output |
| }); |
| } else { |
| return github.rest.issues.createComment({ |
| issue_number: context.issue.number, |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| body: output |
| }); |
| } |
| }); |
| |
| - name: Terraform Apply |
| if: github.ref == 'refs/heads/master' && github.event_name == 'push' |
| env: |
| TF_VAR_buildkite_api_token: ${{ secrets.BUILDKITE_API_TOKEN }} |
| run: terraform apply -auto-approve -input=false |