|
| 1 | +# Re-usable workflow to continuously integrate and deploy OpenToFu plan |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_call: |
| 5 | + inputs: |
| 6 | + tf_version: |
| 7 | + description: 'Version of OpenToFu runtime to use' |
| 8 | + required: false |
| 9 | + type: string |
| 10 | + default: '1.9.0' |
| 11 | + tf_dir: |
| 12 | + description: 'Path to the OpenToFu plan to use' |
| 13 | + required: false |
| 14 | + type: string |
| 15 | + default: './' |
| 16 | + gh_runner_version: |
| 17 | + description: 'Version of the GitHub runner to use' |
| 18 | + required: false |
| 19 | + type: string |
| 20 | + default: 'ubuntu-22.04' |
| 21 | + auto_comment: |
| 22 | + description: 'Enable automatic comment on GitHub pull request' |
| 23 | + required: false |
| 24 | + type: boolean |
| 25 | + default: true |
| 26 | + apply_on_branch: |
| 27 | + description: 'Automaticaly apply plan when on a specific branch' |
| 28 | + required: false |
| 29 | + type: string |
| 30 | + default: '' |
| 31 | + aws_default_region: |
| 32 | + description: 'AWS default region' |
| 33 | + required: false |
| 34 | + type: string |
| 35 | + default: 'eu-central-1' |
| 36 | + secrets: |
| 37 | + aws_access_key_id: |
| 38 | + description: 'AWS access key id' |
| 39 | + required: false |
| 40 | + aws_secret_access_key: |
| 41 | + description: 'AWS secret access key' |
| 42 | + required: false |
| 43 | + hcloud_token: |
| 44 | + description: 'API token for Hetzner Cloud' |
| 45 | + required: false |
| 46 | + |
| 47 | +jobs: |
| 48 | + tf: |
| 49 | + name: OpenToFu |
| 50 | + runs-on: ${{ inputs.gh_runner_version }} |
| 51 | + defaults: |
| 52 | + run: |
| 53 | + working-directory: ${{ inputs.tf_dir }} |
| 54 | + env: |
| 55 | + AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }} |
| 56 | + AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }} |
| 57 | + AWS_DEFAULT_REGION: ${{ inputs.aws_default_region }} |
| 58 | + TF_VAR_hcloud_token: ${{ secrets.hcloud_token }} |
| 59 | + permissions: |
| 60 | + pull-requests: write |
| 61 | + contents: read |
| 62 | + |
| 63 | + steps: |
| 64 | + - name: Checkout |
| 65 | + id: checkout |
| 66 | + uses: actions/checkout@v4 |
| 67 | + |
| 68 | + - name: Setup |
| 69 | + id: setup |
| 70 | + uses: opentofu/setup-opentofu@v1 |
| 71 | + with: |
| 72 | + tofu_version: ${{ inputs.tf_version }} |
| 73 | + |
| 74 | + - name: Format |
| 75 | + id: fmt |
| 76 | + run: tofu fmt -no-color -check -diff |
| 77 | + |
| 78 | + - name: Init |
| 79 | + id: init |
| 80 | + run: tofu init -no-color -input=false |
| 81 | + |
| 82 | + - name: Validate |
| 83 | + id: validate |
| 84 | + run: tofu validate -no-color |
| 85 | + |
| 86 | + - name: Plan |
| 87 | + id: plan |
| 88 | + run: tofu plan -no-color -input=false -compact-warnings -out tf_plan.out |
| 89 | + |
| 90 | + - name: Post Setup |
| 91 | + id: post_setup |
| 92 | + # Only to avoid wrapper to mess up outputs in later steps |
| 93 | + uses: opentofu/setup-opentofu@v1 |
| 94 | + with: |
| 95 | + tofu_version: ${{ inputs.tf_version }} |
| 96 | + tofu_wrapper: false |
| 97 | + |
| 98 | + - name: Verify |
| 99 | + id: verify |
| 100 | + run: | |
| 101 | + # Process the plan to verify the presence of some data |
| 102 | + # which can be used later to make additional checks |
| 103 | + tofu show -no-color tf_plan.out > tf_plan.log 2> >(tee tf_plan.err >&2) && ret=0 || ret=$? |
| 104 | + # Export the plan in json too |
| 105 | + tofu show -json tf_plan.out > tf_plan.json |
| 106 | + # Extract current state from the plan for later comparison |
| 107 | + unzip tf_plan.out tfstate |
| 108 | + # Extract data from temp files and export them as outputs for next steps |
| 109 | + # - changes made, if any |
| 110 | + echo "changes<<tf_verify_changes" >> $GITHUB_OUTPUT |
| 111 | + awk '/the following actions/,0' tf_plan.log >> $GITHUB_OUTPUT |
| 112 | + echo "tf_verify_changes" >> $GITHUB_OUTPUT |
| 113 | + # - summary of the change, if any |
| 114 | + echo "summary<<tf_verify_summary" >> $GITHUB_OUTPUT |
| 115 | + awk '/(Plan: |No changes. )/,1' tf_plan.log | sed -e 's/Plan: /change(s): /' >> $GITHUB_OUTPUT |
| 116 | + echo "tf_verify_summary" >> $GITHUB_OUTPUT |
| 117 | + # - stderr describing errors, if any |
| 118 | + echo "stderr<<tf_verify_stderr" >> $GITHUB_OUTPUT |
| 119 | + cat tf_plan.err >> $GITHUB_OUTPUT |
| 120 | + echo "tf_verify_stderr" >> $GITHUB_OUTPUT |
| 121 | + # Exit with failure if any |
| 122 | + exit $ret |
| 123 | +
|
| 124 | + - name: Comment |
| 125 | + id: update |
| 126 | + if: ${{ always() && github.event_name == 'pull_request' && inputs.auto_comment }} |
| 127 | + uses: actions/github-script@v7 |
| 128 | + with: |
| 129 | + github-token: ${{ github.token }} |
| 130 | + script: | |
| 131 | + // 1. Retrieve existing bot comments for the PR |
| 132 | + const { data: comments } = await github.rest.issues.listComments({ |
| 133 | + owner: context.repo.owner, |
| 134 | + repo: context.repo.repo, |
| 135 | + issue_number: context.issue.number, |
| 136 | + }) |
| 137 | + const botComment = comments.find(comment => { |
| 138 | + return comment.user.type === 'Bot' && comment.body.includes('pr-auto-comment-${{ inputs.tf_dir }}') |
| 139 | + }) |
| 140 | +
|
| 141 | + // 2. Prepare format of the comment, using toJSON to escape any special char |
| 142 | + const changes = [ |
| 143 | + ${{ toJSON(steps.verify.outputs.changes) }}, |
| 144 | + ] |
| 145 | + const errors = [ |
| 146 | + ${{ toJSON(steps.fmt.outputs.stdout) }}, |
| 147 | + ${{ toJSON(steps.init.outputs.stderr) }}, |
| 148 | + ${{ toJSON(steps.validate.outputs.stderr) }}, |
| 149 | + ${{ toJSON(steps.plan.outputs.stderr) }}, |
| 150 | + ${{ toJSON(steps.verify.outputs.stderr) }}, |
| 151 | + ] |
| 152 | + const output = ` |
| 153 | + <!-- pr-auto-comment-${{ inputs.tf_dir }} --> |
| 154 | + ### ${{ github.workflow }} |
| 155 | + | Step | Outcome | |
| 156 | + | ---- | ------- | |
| 157 | + | :pencil2: **Format** | \`${{ steps.fmt.outcome }}\` | |
| 158 | + | :wrench: **Init** ️| \`${{ steps.init.outcome }}\` | |
| 159 | + | :mag: **Validate** | \`${{ steps.validate.outcome }}\` | |
| 160 | + | :page_facing_up: **Plan** | \`${{ steps.plan.outcome }}\` | |
| 161 | + | :passport_control: **Verify** | \`${{ steps.verify.outcome }}\` | |
| 162 | + | :point_right: **Result** | ${{ ( steps.plan.outcome == 'success' && steps.verify.outcome == 'success' && steps.verify.outputs.summary ) || 'with error(s) - see below' }} | |
| 163 | +
|
| 164 | + <details><summary>show change(s)</summary> |
| 165 | +
|
| 166 | + \`\`\` |
| 167 | + ${ changes.filter(function(entry) { return /\S/.test(entry); }).join('\n') } |
| 168 | + \`\`\` |
| 169 | +
|
| 170 | + </details> |
| 171 | +
|
| 172 | + <details><summary>show error(s)</summary> |
| 173 | +
|
| 174 | + \`\`\` |
| 175 | + ${ errors.filter(function(entry) { return /\S/.test(entry); }).join('\n') } |
| 176 | + \`\`\` |
| 177 | +
|
| 178 | + </details> |
| 179 | +
|
| 180 | + *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`* |
| 181 | + *Workflow: \`${{ github.workflow_ref }}\`*`; |
| 182 | +
|
| 183 | + // 3. If we have a comment, update it, otherwise create a new one |
| 184 | + const comment_data = { |
| 185 | + owner: context.repo.owner, |
| 186 | + repo: context.repo.repo, |
| 187 | + body: output |
| 188 | + } |
| 189 | + if (botComment) { |
| 190 | + comment_data.comment_id = botComment.id |
| 191 | + github.rest.issues.updateComment(comment_data) |
| 192 | + } else { |
| 193 | + comment_data.issue_number = context.issue.number |
| 194 | + github.rest.issues.createComment(comment_data) |
| 195 | + } |
| 196 | +
|
| 197 | + - name: Apply |
| 198 | + id: apply |
| 199 | + if: ${{ inputs.apply_on_branch != '' && github.ref == format('refs/heads/{0}', inputs.apply_on_branch) }} |
| 200 | + run: tofu apply -no-color -input=false -auto-approve tf_plan.out |
0 commit comments