Module 5: Apply On Merge The Cd Half
8. Project: Andes Cargo's full plan-to-apply pipeline
Description
This is the project that closes Module 5. Lessons 2 through 7 built each piece separately —apply.yml's trigger, its jobs' chaining, the real apply, concurrency protection, and the complete drift.yml—. This project runs them all together, in sequence, on the same real change Module 3 introduced: the Compliance tag on the andes-cargo-shipment-docs bucket. You're going to follow that change from the Pull Request to the later drift detection, with three separate workflows, three separate act invocations, and the same honesty as always about what really ran and what stays representative without a LocalStack token.
Connection to the module
This project doesn't introduce any new YAML — it's the integration of ci.yml (extended in lesson 3 with upload-artifact), apply.yml (lessons 2 through 5), and drift.yml (lessons 6 and 7), run in the exact order they'd run in a real repository: Pull Request opened → merge → later monitoring. With this, Module 5 completes the CD half of the pipeline; Module 6 builds the missing safety nets.
The complete thread, at a glance
① PULL REQUEST #42 (feature/add-shipment-tags → main)
│
▼
② ci.yml (pull_request) EXECUTED — Module 3 + this module's lesson 3
fmt → init → validate → plan (-out=tfplan) → summary → upload-artifact
│
│ Plan: 12 to add, 0 to change, 0 to destroy
│ "terraform-plan" artifact uploaded, SHA256 recorded
▼
③ (a person reviews the plan, approves, merges the PR)
│
▼
④ apply.yml (push to main) EXECUTED — this module's lessons 2, 3, 4, 5
fetch-reviewed-plan (downloads the SAME artifact, SHA256 verified)
│
▼
terraform-apply (needs: fetch-reviewed-plan)
checkout → setup-terraform → download → init → apply
│
│ real attempt against host.docker.internal:4566
│ REPRESENTATIVE without a token: connection refused, 9 retries
▼
⑤ drift.yml (workflow_dispatch, or daily schedule) EXECUTED — lessons 6 and 7
checkout → setup-terraform (terraform_wrapper: false) → init → plan -detailed-exitcode
│
│ without a real apply completed: Plan: 12 to add → exitcode=2 → "DRIFT DETECTED"
│ REPRESENTATIVE with a successful apply: Plan: 0 to add → exitcode=0 → "no drift"
▼
Andes Cargo, with the complete pipeline tested end-to-end
Step 1 — Confirming the starting point
You're picking up exactly where Module 3 (lesson 8) left you: s3.tf with the Compliance tag already added, ci.yml with nine steps, and Module 3's complete commit history. This lesson adds this module's lesson 3 extension (-out=tfplan + upload-artifact) if you haven't committed it yet:
cd andes-cargo-infra
git log --oneline -3
What to expect (representative for the hashes, literal for the messages — assuming you've already completed this module's lessons 3, 4, and 6):
<hash> drift.yml: schedule-based drift detection with terraform_wrapper: false
<hash> apply.yml: needs, artifact download, and concurrency control
<hash> ci.yml: save the plan as tfplan and upload it as an artifact
export ARTIFACT_ADDR=$(ipconfig getifaddr en0) # Linux: hostname -I | awk '{print $1}'
rm -rf .artifacts && mkdir -p .artifacts
Starting with an empty .artifacts/ makes sure the artifact you're going to see uploaded in Step 2 is really this run's — not a leftover from an earlier session.
Step 2 — ci.yml: Pull Request #42, reviewed
act pull_request -e .github/act-events/pr-event.json -j terraform-checks \
--artifact-server-path ./.artifacts \
--artifact-server-addr "$ARTIFACT_ADDR"
What to expect (literal output, executed to write this lesson — summary of the ten steps, in order):
[ci/terraform-checks] ⭐ Run Main Check out andes-cargo-infra
[ci/terraform-checks] ✅ Success - Main Check out andes-cargo-infra [42.181834ms]
[ci/terraform-checks] ⭐ Run Main Set up Terraform
[ci/terraform-checks] ✅ Success - Main Set up Terraform [3.112824208s]
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks] ✅ Success - Main Terraform format check [823.899875ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks] | Terraform has been successfully initialized!
[ci/terraform-checks] ✅ Success - Main Terraform init [33.162088417s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] | Success! The configuration is valid.
[ci/terraform-checks] ✅ Success - Main Terraform validate [12.453844292s]
[ci/terraform-checks] ⭐ Run Main Install awslocal
[ci/terraform-checks] ✅ Success - Main Install awslocal [13.181793916s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks] | Could not connect to the endpoint URL: "http://host.docker.internal:4566/"
[ci/terraform-checks] Failed but continue next step
[ci/terraform-checks] ❌ Failure - Main Confirm the runner can reach LocalStack on the host [9.505391208s]
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks] ✅ Success - Main Install tflocal [3.144388042s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks] | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks] | Saved the plan to: tfplan
[ci/terraform-checks] ✅ Success - Main Terraform plan [18.483023375s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks] ✅ Success - Main Publish the plan to the job summary [112.681333ms]
[ci/terraform-checks] ⚙ Summary - ## Terraform plan — andes-cargo-infra
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks] | Artifact terraform-plan has been successfully uploaded! Final size is 12484 bytes.
[ci/terraform-checks] | Artifact download URL: https://github.com/nektos/act/actions/runs/1/artifacts/2119430229
[ci/terraform-checks] ✅ Success - Main Upload the plan for apply.yml to use later [1.280550167s]
[ci/terraform-checks] 🏁 Job succeeded
Confirm the tag that started this whole thread, exactly like in Module 3:
grep -A1 "Compliance" plan-output.txt
What to expect (literal):
+ "Compliance" = "manifest-retention-required"
+ "Environment" = "dev"
This is the exact moment where, on a real repository, a person would come in to review the job summary, see the new tag on the right bucket, nothing destroyed, and approve merging PR #42. Nothing that follows can happen without this step — it's the CI half that gives meaning to the CD half that follows.
Step 3 — The (simulated) merge and apply.yml
On a real repository, merging PR #42 triggers a push to main. Simulate it by running apply.yml directly —the same command from lesson 4—:
act push -W .github/workflows/apply.yml \
--artifact-server-path ./.artifacts \
--artifact-server-addr "$ARTIFACT_ADDR"
What to expect (literal output, executed to write this lesson):
[apply/fetch-reviewed-plan] ⭐ Run Main Download the plan reviewed in the pull request
[apply/fetch-reviewed-plan] | SHA256 digest of downloaded artifact is 17f7ebe9b774bb3520631bf3e252311300139416f0082e95103304af6e033900
[apply/fetch-reviewed-plan] ✅ Success - Main Download the plan reviewed in the pull request [972.244958ms]
[apply/fetch-reviewed-plan] ⭐ Run Main Confirm the plan file arrived intact
[apply/fetch-reviewed-plan] | tfplan is present: 15826 bytes
[apply/fetch-reviewed-plan] ✅ Success - Main Confirm the plan file arrived intact [119.824ms]
[apply/fetch-reviewed-plan] 🏁 Job succeeded
[apply/terraform-apply ] ⭐ Run Main Download the plan reviewed in the pull request
[apply/terraform-apply ] | SHA256 digest of downloaded artifact is 17f7ebe9b774bb3520631bf3e252311300139416f0082e95103304af6e033900
[apply/terraform-apply ] ✅ Success - Main Download the plan reviewed in the pull request [970.303916ms]
[apply/terraform-apply ] ⭐ Run Main Terraform init
[apply/terraform-apply ] | Terraform has been successfully initialized!
[apply/terraform-apply ] ✅ Success - Main Terraform init [35.464288875s]
[apply/terraform-apply ] ⭐ Run Main Install tflocal
[apply/terraform-apply ] ✅ Success - Main Install tflocal [8.011445042s]
[apply/terraform-apply ] ⭐ Run Main Terraform apply
[apply/terraform-apply ] | module.shipment_docs_bucket.aws_s3_bucket.this: Creating...
[apply/terraform-apply ] | aws_dynamodb_table.shipments: Creating...
[apply/terraform-apply ] | module.app_server_role.aws_iam_role.this: Creating...
[apply/terraform-apply ] | module.lambda_manifest_processor_role.aws_iam_role.this: Creating...
[apply/terraform-apply ] | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m40s elapsed]
[apply/terraform-apply ] | ╷
[apply/terraform-apply ] | │ Error: creating S3 Bucket (andes-cargo-shipment-docs): operation error S3: CreateBucket, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply ] | ╵
[apply/terraform-apply ] ❗ ::error::Terraform exited with code 1.
[apply/terraform-apply ] ❌ Failure - Main Terraform apply [1m3.705523875s]
[apply/terraform-apply ] 🏁 Job failed
Error: Job 'terraform-apply' failed
The SHA256 matches again, across all three appearances —the one uploaded in ci.yml, the one downloaded in fetch-reviewed-plan, the one downloaded in terraform-apply—: 17f7ebe9b774... in all three. It's the proof, repeated for the third time in this module, that the file that almost got applied is exactly the one reviewed in PR #42 — no reprint, no recalculation. The final failure is the same as always: a real attempt, real retries from the AWS provider, connection refused because LocalStack isn't running on this machine.
Step 4 — drift.yml, immediately after
Even though this specific run's apply never actually completed, Andes Cargo's pipeline includes drift.yml running after any infrastructure change —scheduled daily, or triggered by hand after a major deployment—. Confirm it, running the same job from lessons 6 and 7:
act workflow_dispatch -j check-drift -W .github/workflows/drift.yml
What to expect (literal output, executed to write this lesson):
[drift-detection/check-drift] ⭐ Run Main Terraform plan (read-only drift check)
[drift-detection/check-drift] | Plan: 12 to add, 0 to change, 0 to destroy.
[drift-detection/check-drift] ✅ Success - Main Terraform plan (read-only drift check) [17.596187875s]
[drift-detection/check-drift] ⚙ ::set-output:: exitcode=2
[drift-detection/check-drift] ⭐ Run Main Report drift status
[drift-detection/check-drift] ⚙ Summary - ## Drift check — andes-cargo-infra
Triggered by: workflow_dispatch
Result: DRIFT DETECTED. Terraform found differences between the state and reality.
[drift-detection/check-drift] 🏁 Job succeeded
Read this with the same precision lesson 7 already taught. drift.yml reports "DRIFT DETECTED" — mechanically correct, because the state still has no resources registered (Step 3's apply never got to complete). It's not the signal that someone touched something by hand outside the pipeline —the scenario drift.yml is designed to catch in real production—; it's the honest consequence of running the complete pipeline on a machine with no LOCALSTACK_AUTH_TOKEN. The mechanism —-detailed-exitcode, terraform_wrapper: false, the report in $GITHUB_STEP_SUMMARY— is exactly the same one that would run in production; the only thing that changes is the cause behind the result.
The complete walkthrough, with a valid token (representative)
So the thread is complete, here's what you'd see if every step in this project ran against a real LocalStack, with LOCALSTACK_AUTH_TOKEN exported from Module 1 (lesson 8, Step 5):
What to expect (representative — the complete sequence, without a live run against a valid token at this moment):
① ci.yml (pull_request)
Plan: 12 to add, 0 to change, 0 to destroy.
Artifact terraform-plan uploaded successfully.
② apply.yml (push, after merge)
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
Outputs: shipments_table_name = "Shipments", ...
③ drift.yml (workflow_dispatch, run right after the apply)
Plan: 0 to add, 0 to change, 0 to destroy.
exitcode=0
Result: no drift detected. Infrastructure matches the state.
The difference between this block and what you really ran in Steps 2 through 4 isn't in any workflow's mechanism —all three files are identical in both cases— but exclusively in whether there's a real LocalStack on the other side of host.docker.internal:4566. This block's ③ is the final confirmation that the complete cycle works: a successful apply leaves the state reflecting reality, and the next drift.yml confirms it with exitcode=0, not 2.
Committing the module's close
git add -A
git commit -m "Wire ci.yml, apply.yml, and drift.yml into a single plan-to-apply pipeline"
git log --oneline
What to expect (representative for the hashes, literal for the structure):
<hash> Wire ci.yml, apply.yml, and drift.yml into a single plan-to-apply pipeline
<hash> drift.yml: schedule-based drift detection with terraform_wrapper: false
<hash> apply.yml: concurrency control to prevent a double apply
<hash> apply.yml: needs, artifact download, and the real terraform apply attempt
<hash> ci.yml: save the plan as tfplan and upload it as an artifact
a10c9b7 Add Compliance tag to the shipment-docs bucket
Module 5 close
You completed the CD half of Andes Cargo's pipeline. Review what you're taking with you:
- The correct trigger, with its known limit:
pushtomain, confirmed by running with a synthetic feature-branch eventactdidn't block —the same finding from Module 2, now with direct consequences on the file that applies real infrastructure (lesson 2). - The exact plan, not a new one:
needs:within a single file,upload-artifact/download-artifactbetween separate runs, verified three times by identical SHA256 —and two real networking findings: the--artifact-server-addrthat needs your real IP under Docker Desktop, and whyrun_idfixed at1letsdownload-artifactwork withoutrun-id:underact, something a real repository would actually need (lesson 3). - The real
apply, genuinely attempted:tflocal apply -auto-approve tfplan, with an honestconnection refusedfailure after 9 retries from the AWS provider —slower and more detailed than earlier modules'awslocalfailures, for the same reason as always: LocalStack isn't running (lesson 4). - Protection against the double apply:
concurrency: { group, cancel-in-progress: false }, with the exact reason behind choosing to wait rather than cancel for infrastructure, and the honesty thatactcan't prove the lock between runs —a centralized GitHub coordination mechanism, likeenvironment:(lesson 5). - Scheduled monitoring:
drift.yml, with-detailed-exitcodeand a real, documented finding aboutterraform_wrapper: falseinhashicorp/setup-terraform@v3—without it, the job would incorrectly report "no drift," even when there is (lessons 6 and 7). - All together, on the same real change: the
Compliancetag, following the thread from PR #42 (Module 2) through Module 3, and now through a real application attempt and its subsequent monitoring (this project).
What comes next
Module 6 teaches what to do when something goes wrong: infrastructure's specific rollback pattern (git revert, not "roll back to an image version"), branch protection as the real repository control that blocks main from receiving changes that skip ci.yml, and a real guardrail —executed, not just named— that fails the job if a plan tries to destroy the Shipments table. With that, Andes Cargo's complete pipeline ends up with the three layers an infrastructure CI/CD system needs: review (Module 3), automatic application (this module), and safety nets (Module 6).
Common mistakes
Expecting Step 4 to show "no drift" (expectation-based, revisit lesson 7). What happens: someone, after seeing Step 3's apply.yml, expects drift.yml to confirm "everything's fine" right afterward. How to fix it: Step 3's apply never completed —it failed for lack of a connection to LocalStack—, so there's no new resource for the state to reflect. drift.yml reporting "DRIFT DETECTED" in Step 4 is the correct, expected consequence of that chain of events, not a new bug.
Running the three workflows without clearing .artifacts/ first (workflow-based). What happens: someone runs this project several times in a row, without deleting the artifacts folder between runs, and ends up with an earlier run's SHA256 mixed with the current one's. How to fix it: this lesson's Step 1 includes rm -rf .artifacts && mkdir -p .artifacts exactly for this — starting clean on every complete pipeline walkthrough avoids confusion about which artifact belongs to which run.
Thinking this project replaces the Module 2 and Module 3 projects (scope-based). What happens: someone assumes that, with this project, hello-andes-cargo.yml or the original ci.yml no longer matter. How to fix it: every file in .github/workflows/ still exists and still fulfills its role — ci.yml runs on every new Pull Request, forever; this project only demonstrates how those files, alongside this module's two new ones, work together on a specific change.
Exercises
Exercise 1 — Reconstruct the SHA256 chain from memory. Without looking at this lesson, explain at how many separate points in this project the same SHA256 was verified, and what specific guarantee each verification provides.
See solution
Three points: (1) when ci.yml uploads the artifact (the SHA256 is calculated and recorded for the first time); (2) when fetch-reviewed-plan, in apply.yml, downloads it (confirms what arrived is exactly what was uploaded); (3) when terraform-apply, in the same apply.yml, downloads it again in its own container (confirms the same thing, a second time, independently). All three matches, together, are the proof that the file that almost got applied was never modified between the moment someone reviewed it (Step 2) and the moment it was attempted to be applied (Step 3).
Exercise 2 — Explain why Step 4 is still useful, even though its result right now isn't "real drift." A colleague tells you: "if Step 4 didn't detect real drift, what's the point of running it in this project?" Answer them.
See solution
A complete answer sounds, roughly, like this: "This step's purpose isn't only today's shown result —it confirms the whole mechanism works: terraform_wrapper: false propagates the correct exit code, -detailed-exitcode distinguishes 'no changes' from 'changes present,' and the summary gets published with the right message based on that code. We already saw, in lessons 6 and 7, that without that flag the job would incorrectly report 'no drift' even with real changes present —running it here, on this real project, is the final confirmation that finding still applies, not just in an isolated example."
Exercise 3 — Design Andes Cargo's next step. If you had a valid LOCALSTACK_AUTH_TOKEN right now, in what exact order would you run this project's commands to see the complete successful walkthrough, from start to finish?
See solution
The exact same order as this lesson, with no command changed: (1) start LocalStack with the token (Module 1, lesson 8, Step 5); (2) act pull_request -e pr-event.json -j terraform-checks with the artifact flags (Step 2); (3) act push -W apply.yml with the same flags (Step 3) — this time it would end with Apply complete! Resources: 12 added; (4) act workflow_dispatch -j check-drift (Step 4) — this time it would end with exitcode=0, Result: no drift detected. The command sequence never changes; the only thing that changes is whether there's a real LocalStack on the other side.
Summary and next step
In this project you ran ci.yml, apply.yml, and drift.yml in sequence, on the same real change that has run through this guide since Module 2: PR #42's Compliance tag. You verified, with identical SHA256 at three separate points, that the applied plan is exactly the reviewed plan. You saw the real terraform apply attempt fail honestly without a token, and drift.yml correctly report on a state that still has nothing applied. You closed with the complete representative walkthrough —plan → successful apply → clean drift— so the full cycle is clear, even without being able to run it end-to-end on this machine.
Before moving on you should be able to: describe from memory the complete walkthrough of an Andes Cargo change, from the Pull Request to the later monitoring; explain exactly what guarantees the SHA256 matches across all three verification points; and precisely say what would change in each of the three workflows if you ran this same project with a valid LOCALSTACK_AUTH_TOKEN.
With this, Module 5 is closed. You have the complete CD half of the pipeline: automatic application, protected against concurrency, with scheduled drift monitoring.
Next module: rollback and safety nets — what to do when an apply goes wrong, branch protection as a real repository control, and an executed guardrail that protects the Shipments table from accidental destruction.
Resources
- HashiCorp Developer — Automate Terraform with GitHub Actions — the complete pattern this module finishes implementing, cited since Module 3.
- nektosact.com — User Guide — complete reference for
act pull_request,act push -W, andact workflow_dispatch, used end-to-end in this project. - GitHub Docs — Using concurrency — official reference for
concurrency:, integrated intoapply.ymlsince lesson 5. - This guide's Module 3 (
08-project-andes-cargos-ci-workflow.md) — the origin of the HCL change (Compliancetag) this project follows through to its application attempt.