diff --git a/.github/examples/pr_merge_matrix.yaml b/.github/examples/pr_merge_matrix.yaml index bf75e313..14a80c24 100644 --- a/.github/examples/pr_merge_matrix.yaml +++ b/.github/examples/pr_merge_matrix.yaml @@ -37,3 +37,4 @@ jobs: arg_lock: ${{ github.event_name == 'merge_group' && 'true' || 'false' }} arg_var_file: env/${{ matrix.deployment }}.tfvars arg_workspace: ${{ matrix.deployment }} + plan_parity: true diff --git a/.github/workflows/tf_tests.yaml b/.github/workflows/tf_tests.yaml index 898d18b7..62466509 100644 --- a/.github/workflows/tf_tests.yaml +++ b/.github/workflows/tf_tests.yaml @@ -46,6 +46,7 @@ jobs: continue-on-error: true uses: ./ with: + plan_parity: true arg_chdir: tests/${{ matrix.path }} arg_command: ${{ github.event.pull_request.merged && 'apply' || 'plan' }} arg_lock: ${{ github.event.pull_request.merged && 'true' || 'false' }} diff --git a/README.md b/README.md index edeecd64..c2b9e097 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ In order to locally decrypt the TF plan file, use the following command (noting | `encrypt_passphrase`
Example: `${{ secrets.KEY }}` | String passphrase to encrypt the TF plan file. | | `fmt_enable`
Default: `true` | Boolean flag to enable TF fmt command and display diff of changes. | | `label_pr`
Default: `true` | Boolean flag to add PR label of TF command to run. | -| `outline_enable`
Default: `true` | Boolean flag to add an outline diff of TF plan file. | +| `plan_parity`
Default: `false` | Boolean flag to compare the TF plan file with a newly-generated one to prevent stale apply. | | `tf_tool`
Default: `terraform` | String name of the TF tool to use and override default assumption from wrapper environment variable. | | `tf_version`
Example: `~>` 1.8.0 | String version constraint of the TF tool to install and use. | | `update_comment`
Default: `false` | Boolean flag to update existing PR comment instead of creating a new comment and deleting the old one. | diff --git a/action.js b/action.js index a64438e2..10043df9 100644 --- a/action.js +++ b/action.js @@ -247,30 +247,61 @@ module.exports = async ({ context, core, exec, github }) => { 3 ); - if (/^true$/i.test(process.env.outline_enable)) { - result_outline = cli_result - .split("\n") - .filter((line) => line.startsWith(" # ")) - .map((line) => { - const diff_line = line.slice(4); - if (diff_line.includes(" created")) return "+ " + diff_line; - if (diff_line.includes(" destroyed")) return "- " + diff_line; - if (diff_line.includes(" updated") || diff_line.includes(" replaced")) - return "! " + diff_line; - return "# " + diff_line; - }) - .join("\n"); - if (result_outline?.length >= result_outline_limit) { - result_outline = result_outline.substring(0, result_outline_limit) + "…"; - } - core.setOutput("outline", result_outline); + result_outline = cli_result + .split("\n") + .filter((line) => line.startsWith(" # ")) + .map((line) => { + const diff_line = line.slice(4); + if (diff_line.includes(" created")) return "+ " + diff_line; + if (diff_line.includes(" destroyed")) return "- " + diff_line; + if (diff_line.includes(" updated") || diff_line.includes(" replaced")) + return "! " + diff_line; + return "# " + diff_line; + }) + .join("\n"); + if (result_outline?.length >= result_outline_limit) { + result_outline = result_outline.substring(0, result_outline_limit) + "…"; } + core.setOutput("outline", result_outline); } // TF apply. if (process.env.arg_command === "apply") { // Download the TF plan file if not auto-approved. if (!/^true$/i.test(process.env.auto_approve)) { + // TF plan anew for later comparison if plan_parity is enabled. + if (/^true$/i.test(process.env.plan_parity)) { + await exec_tf( + [ + process.env.arg_chdir, + "plan", + process.env.arg_out + ".new", + process.env.arg_var_file, + process.env.arg_destroy, + process.env.arg_compact_warnings, + process.env.arg_concise, + process.env.arg_detailed_exitcode, + process.env.arg_generate_config_out, + process.env.arg_json, + process.env.arg_lock_timeout, + process.env.arg_lock, + process.env.arg_parallelism, + process.env.arg_refresh_only, + process.env.arg_refresh, + process.env.arg_replace, + process.env.arg_target, + process.env.arg_var, + ], + [ + "plan", + process.env.arg_chdir, + process.env.arg_workspace_alt, + process.env.arg_backend_config, + ], + 3 + ); + } + process.env.arg_auto_approve = process.env.arg_out.replace(/^-out=/, ""); process.env.arg_var_file = process.env.arg_var = ""; @@ -327,9 +358,8 @@ module.exports = async ({ context, core, exec, github }) => { `mv ${working_directory}.decrypted ${working_directory}`, ]); } - } - if (/^true$/i.test(process.env.outline_enable)) { + // Generate an outline of the TF plan. await exec_tf( [process.env.arg_chdir, "show", process.env.arg_out.replace(/^-out=/, "")], [ @@ -354,6 +384,53 @@ module.exports = async ({ context, core, exec, github }) => { return "# " + diff_line; }) .join("\n"); + + // Compare normalized output of the old TF plan with the new one. + // If they match, then replace the old TF plan with the new one to avoid stale apply. + // Otherwise, proceed with the stale apply. + if (/^true$/i.test(process.env.plan_parity)) { + await exec_tf( + [process.env.arg_chdir, "show", process.env.arg_out.replace(/^-out=/, "") + ".new"], + [ + "show", + process.env.arg_chdir, + process.env.arg_workspace_alt, + process.env.arg_backend_config, + process.env.arg_var_file, + process.env.arg_destroy, + ], + 2 + ); + + const result_outline_old = result_outline.split("\n").sort().join("\n"); + const result_outline_new = cli_result + .split("\n") + .filter((line) => line.startsWith(" # ")) + .map((line) => { + const diff_line = line.slice(4); + if (diff_line.includes(" created")) return "+ " + diff_line; + if (diff_line.includes(" destroyed")) return "- " + diff_line; + if (diff_line.includes(" updated") || diff_line.includes(" replaced")) + return "! " + diff_line; + return "# " + diff_line; + }) + .sort() + .join("\n"); + + if (result_outline_old === result_outline_new) { + await exec.exec("/bin/bash", [ + "-c", + `mv ${process.env.arg_chdir.replace(/^-chdir=/, "")}/${process.env.arg_out.replace( + /^-out=/, + "" + )}.new ${process.env.arg_chdir.replace(/^-chdir=/, "")}/${process.env.arg_out.replace( + /^-out=/, + "" + )}`, + ]); + } + } + if (result_outline?.length >= result_outline_limit) { result_outline = result_outline.substring(0, result_outline_limit) + "…"; } @@ -423,8 +500,8 @@ module.exports = async ({ context, core, exec, github }) => { // Render the TF fmt command output. const output_fmt = process.env.arg_command === "plan" && - /^true$/i.test(process.env.fmt_enable) && - fmt_result?.length + /^true$/i.test(process.env.fmt_enable) && + fmt_result?.length ? `
Format diff check. \`\`\`diff @@ -456,11 +533,10 @@ ${output_outline}
${result_summary}
-###### ${context.workflow} by @${context.actor} via [${context.eventName}](${check_url}) at ${ - context.payload.pull_request?.updated_at || +###### ${context.workflow} by @${context.actor} via [${context.eventName}](${check_url}) at ${context.payload.pull_request?.updated_at || context.payload.head_commit?.timestamp || context.payload.merge_group?.head_commit.timestamp - }. + }.
\`\`\`hcl diff --git a/action.yml b/action.yml index 9f827be6..951c007b 100644 --- a/action.yml +++ b/action.yml @@ -16,6 +16,10 @@ inputs: description: Boolean flag to add PR comment of TF command output. required: false default: "true" + plan_parity: + description: Boolean flag to compare the TF plan file with a newly-generated one to prevent stale apply. + required: false + default: "false" encrypt_passphrase: description: String passphrase to encrypt the TF plan file. required: false @@ -28,10 +32,6 @@ inputs: description: Boolean flag to add PR label of TF command to run. required: false default: "true" - outline_enable: - description: Boolean flag to add an outline diff of TF plan file. - required: false - default: "true" tf_tool: description: String name of the TF tool to use and override default assumption from wrapper environment variable. required: false @@ -282,10 +282,10 @@ runs: # Input parameters. cache_hit: ${{ steps.cache_plugins.outputs.cache-hit }} comment_pr: ${{ inputs.comment_pr }} + plan_parity: ${{ inputs.plan_parity }} encrypt_passphrase: ${{ inputs.encrypt_passphrase }} fmt_enable: ${{ inputs.fmt_enable }} label_pr: ${{ inputs.label_pr }} - outline_enable: ${{ inputs.outline_enable }} tf_tool: ${{ inputs.tf_tool }} update_comment: ${{ inputs.update_comment }} validate_enable: ${{ inputs.validate_enable }}