From 0ac2e3f8544bd7092dd881da6a70fdca16ad1df2 Mon Sep 17 00:00:00 2001 From: Leonard Du Date: Tue, 28 Jul 2026 15:25:49 +1000 Subject: [PATCH 1/2] ci(terraform-integration): fold matrix + tests into one job to cut approval emails Each matrix job with environment: terraform-privileged is its own deployment request, and GitHub sends one 'Waiting for review' email per deployment request per reviewer. For ~20 tfvars that's ~40 emails per push. Fold terraform-test + setup-matrix + terraform-plan into a single terraform-integration job. Only one deployment request per run, so each reviewer receives one email instead of 40. Behavior preserved: * AWS test, Azure test, and PR-diff-driven plan selection all still run. * If a single cloud's .tf changed, only that cloud is planned; if scenarios// changed, only those scenarios; otherwise nothing. * Failures are collected across all scenarios so a single broken plan doesn't hide the rest; step fails at the end if anything failed. * ::group:: markers per scenario keep the log scannable. Trade-offs: * Serial rather than fan-out. AWS test + Azure test + N plans run sequentially in one runner. For ~20 scenarios this is ~15-25 minutes vs the old ~5-8 minutes fan-out. * One log for everything (grouped by ::group::). --- .github/workflows/terraform-integration.yml | 196 ++++++++++---------- 1 file changed, 94 insertions(+), 102 deletions(-) diff --git a/.github/workflows/terraform-integration.yml b/.github/workflows/terraform-integration.yml index 83811e24e4..947df48fe2 100644 --- a/.github/workflows/terraform-integration.yml +++ b/.github/workflows/terraform-integration.yml @@ -19,7 +19,7 @@ env: TERRAFORM_AWS_MODULES_DIR: modules/terraform/aws jobs: - terraform-test: + terraform-integration: environment: terraform-privileged permissions: contents: read @@ -31,6 +31,7 @@ jobs: with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 persist-credentials: false - name: Setup Terraform @@ -59,119 +60,110 @@ jobs: env: ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - setup-matrix: - runs-on: ubuntu-latest - name: Setup terraform plan matrix for test scenarios - steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Setup test matrix scenarios - id: setup-matrix-scenarios + - name: Compute scenarios to plan + id: scenarios + env: + FULL_MATRIX_QUERY: | + find "$GITHUB_WORKSPACE/scenarios/" -name '*.tfvars' | grep 'azure' | awk -F'/' '{ + split($11, file_name, "."); + split(file_name[1], cloud_region, "-"); + region = (length(cloud_region) > 1) ? substr($11, index($11, "-") + 1) : ""; + cloud = cloud_region[1]; + gsub(".json", "", region); + printf "{\"cloud\": \"%s\", \"file_name\": \"%s\", \"scenario_type\": \"%s\", \"scenario_name\": \"%s\"}\n", cloud, file_name[1], $8, $9; + }' | sort -u | jq -s 'unique' run: | - set -eux - matrix=$(find $GITHUB_WORKSPACE/scenarios/ -name "*.tfvars" | grep 'azure' | awk -F'/' '{split($11, file_name, "."); split(file_name[1], cloud_region, "-");region= (length(cloud_region) > 1) ? substr($11, index($11, "-") + 1) : ""; cloud=cloud_region[1]; gsub(".json", "", region); print "{\"cloud\": \"" cloud "\", \"file_name\": \"" file_name[1] "\", " (region != "" ? "\"region\": \"" region "\", " : "") "\"scenario_type\": \"" $8 "\", \"scenario_name\": \"" $9 "\"},"}' | sort | uniq | sed 's/,$/,/') - matrix_array="[${matrix%,}]" + set -eu + matrix_array=$(bash -c "$FULL_MATRIX_QUERY") - file_changes=$(git diff --name-only -r origin/main HEAD) + file_changes=$(git diff --name-only -r origin/main HEAD || true) echo "PR file changes: $file_changes" if [ -z "$file_changes" ]; then - matrix_combinations=null - echo "matrix_combinations=$matrix_combinations" >> "$GITHUB_OUTPUT" - else - cloud_changes=$(echo "$file_changes" | grep -E '\.tf$' | awk -F'/' '{print $3}' | uniq) - echo "File changes related to cloud: $cloud_changes" - - run_all_tests=false - module_matrix='[]' - scenario_matrix='[]' - if [ $(echo "$cloud_changes" | wc -w) -eq 1 ]; then - cloud=$(echo "$cloud_changes" | cut -d' ' -f1) - module_matrix=$(echo "$matrix_array" | jq --arg cloud_value "$cloud" '. | map(select(.cloud == $cloud_value))') - elif [ $(echo "$cloud_changes" | wc -w) -eq 0 ]; then - run_all_tests=false - else - run_all_tests=true - module_matrix=$(echo "$matrix_array") - fi - echo "module_matrix: $module_matrix" - if [ "$run_all_tests" = false ]; then - changed_scenario_names=$(echo "$file_changes" | grep -E '^scenarios/' | awk -F'/' '{print $3}' | tr ' ' '\n' | sort -u) - echo "Test scenarios changed: $changed_scenario_names" - for scenario_name in $changed_scenario_names; do - filtered_objects=$(echo "$matrix_array" | jq --arg scenario_value "$scenario_name" '. | map(select(.scenario_name == $scenario_value))') - scenario_matrix=$(echo "$scenario_matrix $filtered_objects" | jq -s 'flatten | unique') - done - fi - updated_matrix=$(echo "$module_matrix $scenario_matrix" | jq -s 'flatten | unique') - echo "Test scenarios to run: $updated_matrix" - updated_matrix="${updated_matrix//$'\n'/''}" - matrix_combinations="{\"include\": ${updated_matrix}}" - echo "matrix_combinations=$matrix_combinations" >> "$GITHUB_OUTPUT" + echo "scenarios=[]" >> "$GITHUB_OUTPUT" + echo "No file changes -> skipping plans." + exit 0 fi - echo "matrix_combinations: $matrix_combinations" - outputs: - matrix-combinations: ${{ steps.setup-matrix-scenarios.outputs.matrix_combinations }} - terraform-plan: - needs: setup-matrix - environment: terraform-privileged - permissions: - id-token: write - contents: read - if: ${{ needs.setup-matrix.result == 'success' && needs.setup-matrix.outputs.matrix-combinations != '' && needs.setup-matrix.outputs.matrix-combinations != 'null' }} - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix-combinations) }} - runs-on: ubuntu-latest - name: ${{ matrix.cloud }}-${{ matrix.scenario_type }}-${{ matrix.scenario_name }} ${{ matrix.region }} + cloud_changes=$(echo "$file_changes" | grep -E '\.tf$' | awk -F'/' '{print $3}' | uniq || true) + echo "File changes related to cloud: $cloud_changes" - steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false + run_all_tests=false + module_matrix='[]' + scenario_matrix='[]' + if [ "$(echo "$cloud_changes" | wc -w)" -eq 1 ]; then + cloud=$(echo "$cloud_changes" | cut -d' ' -f1) + module_matrix=$(echo "$matrix_array" | jq --arg c "$cloud" 'map(select(.cloud == $c))') + elif [ "$(echo "$cloud_changes" | wc -w)" -eq 0 ]; then + run_all_tests=false + else + run_all_tests=true + module_matrix=$matrix_array + fi - - name: Setup Terraform - uses: hashicorp/setup-terraform@v2 - with: - terraform_version: 1.10.0 + if [ "$run_all_tests" = false ]; then + changed_scenario_names=$(echo "$file_changes" | grep -E '^scenarios/' | awk -F'/' '{print $3}' | tr ' ' '\n' | sort -u || true) + for scenario_name in $changed_scenario_names; do + filtered=$(echo "$matrix_array" | jq --arg s "$scenario_name" 'map(select(.scenario_name == $s))') + scenario_matrix=$(echo "$scenario_matrix $filtered" | jq -s 'flatten | unique') + done + fi - - name: Get job id and set env - run: | - echo "TERRAFORM_INPUT_FILE=$GITHUB_WORKSPACE/scenarios/${{ matrix.scenario_type }}/${{ matrix.scenario_name }}/terraform-inputs/${{ matrix.file_name }}.tfvars" >> "$GITHUB_ENV" - echo "TERRAFORM_TEST_INPUT_FILE=$GITHUB_WORKSPACE/scenarios/${{ matrix.scenario_type }}/${{ matrix.scenario_name }}/terraform-test-inputs/${{ matrix.file_name }}.json" >> "$GITHUB_ENV" - echo "TERRAFORM_MODULES_DIR=modules/terraform/${{ matrix.cloud }}" >> "$GITHUB_ENV" + final=$(echo "$module_matrix $scenario_matrix" | jq -s 'flatten | unique') + echo "Scenarios to plan: $final" + echo "scenarios=$(echo "$final" | jq -c '.')" >> "$GITHUB_OUTPUT" - - name: Create JSON Input + - name: Terraform Plan for every scenario + if: steps.scenarios.outputs.scenarios != '[]' + env: + ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + SCENARIOS_JSON: ${{ steps.scenarios.outputs.scenarios }} run: | - INPUT_JSON_STRING=$(jq -c '. + {creation_time: (now | todateiso8601)}' "${{ env.TERRAFORM_TEST_INPUT_FILE }}") - echo "INPUT_JSON=$INPUT_JSON_STRING" >> "$GITHUB_ENV" + set -eu + + # Collect failures instead of bailing at the first one, so a single + # bad scenario doesn't hide the rest. + FAILED="" + + # Iterate via process substitution so we can mutate FAILED without + # losing it to a subshell. + while IFS=$'\t' read -r cloud scenario_type scenario_name file_name; do + modules_dir="modules/terraform/$cloud" + tfvars="$GITHUB_WORKSPACE/scenarios/$scenario_type/$scenario_name/terraform-inputs/${file_name}.tfvars" + test_input="$GITHUB_WORKSPACE/scenarios/$scenario_type/$scenario_name/terraform-test-inputs/${file_name}.json" + + label="$cloud/$scenario_type/$scenario_name/$file_name" + echo "::group::plan $label" + + if [ ! -f "$tfvars" ]; then + echo "::error::missing tfvars: $tfvars" + FAILED="${FAILED}${label}\n" + echo "::endgroup::" + continue + fi + if [ ! -f "$test_input" ]; then + echo "::error::missing test input: $test_input" + FAILED="${FAILED}${label}\n" + echo "::endgroup::" + continue + fi - - name: Azure login - if: ${{ matrix.cloud == 'azure' }} - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_MI }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + INPUT_JSON=$(jq -c '. + {creation_time: (now | todateiso8601)}' "$test_input") - - name: Terraform Init - working-directory: ${{ env.TERRAFORM_MODULES_DIR }} - run: terraform init + if ! ( + cd "$modules_dir" + terraform init -input=false + terraform plan \ + -var-file "$tfvars" \ + -var="json_input=$INPUT_JSON" + ); then + FAILED="${FAILED}${label}\n" + fi + echo "::endgroup::" + done < <(echo "$SCENARIOS_JSON" | jq -r '.[] | [.cloud, .scenario_type, .scenario_name, .file_name] | @tsv') - - name: Terraform Plan - working-directory: ${{ env.TERRAFORM_MODULES_DIR }} - run: | - terraform plan -var-file "$TERRAFORM_INPUT_FILE" -var="json_input=$INPUT_JSON" - env: - ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - CLOUD: ${{ matrix.cloud }} + if [ -n "$FAILED" ]; then + echo "::error::Failed scenarios:" + printf ' %b' "$FAILED" + exit 1 + fi From 0b5e0bc1d8075ef1715ec1827dae3219fddd275e Mon Sep 17 00:00:00 2001 From: Leonard Du Date: Tue, 28 Jul 2026 16:10:37 +1000 Subject: [PATCH 2/2] ci(terraform-integration): use GitHub API for changed files, drop fetch-depth: 0 Addresses Copilot review on #1271: git diff --name-only -r origin/main HEAD silently returns empty if origin/main isn't present. That's especially likely on fork PRs (the checkout is against the fork repo, whose origin doesn't have the base branch), which would cause the workflow to skip plans even when Terraform changed. Fetch the PR's changed files via the GitHub REST API instead. Same set of files, but doesn't depend on git remote refs, so we can also drop fetch-depth: 0 (originally added only to support git diff). --- .github/workflows/terraform-integration.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/terraform-integration.yml b/.github/workflows/terraform-integration.yml index 947df48fe2..1664542371 100644 --- a/.github/workflows/terraform-integration.yml +++ b/.github/workflows/terraform-integration.yml @@ -31,7 +31,6 @@ jobs: with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 persist-credentials: false - name: Setup Terraform @@ -63,6 +62,9 @@ jobs: - name: Compute scenarios to plan id: scenarios env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} FULL_MATRIX_QUERY: | find "$GITHUB_WORKSPACE/scenarios/" -name '*.tfvars' | grep 'azure' | awk -F'/' '{ split($11, file_name, "."); @@ -76,8 +78,12 @@ jobs: set -eu matrix_array=$(bash -c "$FULL_MATRIX_QUERY") - file_changes=$(git diff --name-only -r origin/main HEAD || true) - echo "PR file changes: $file_changes" + # Ask the GitHub API for the PR's changed files. This avoids depending + # on git remote refs (fork PRs don't have origin/main pointing at the + # base branch), and doesn't require fetch-depth: 0. + file_changes=$(gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename') + echo "PR file changes:" + echo "$file_changes" if [ -z "$file_changes" ]; then echo "scenarios=[]" >> "$GITHUB_OUTPUT"