Module 5: Apply On Merge The Cd Half

6. Scheduled drift detection

Description

Until now, every piece of this pipeline has trusted one assumption: that the only way to change Andes Cargo's infrastructure is through apply.yml. That trust can break at any moment —someone with console access, or awslocal directly, changes something by hand, out of urgency or by mistake— and neither ci.yml nor apply.yml would find out, because neither runs unless someone opens a Pull Request or merges one. This lesson builds drift.yml, the workflow that watches that gap: a read-only terraform plan, run periodically, that compares what Terraform believes exists against what really exists.

Connection to the module

This lesson revisits the cron: syntax you already saw in Module 2 (lesson 4) —it doesn't repeat it from scratch, it applies it to a real case—. Lesson 7 runs drift.yml's job by hand, with act workflow_dispatch, and shows how a real drift would read if there were one. Lesson 8 —this module's project— integrates drift.yml into Andes Cargo's complete pipeline.


Analogy: the guard who does the rounds, not the camera that records everything

ci.yml and apply.yml are like an electronic lock: they react to a specific event (someone tries to open the door) and act in the moment. drift.yml is different — it's the guard who does a round at a fixed time, checking that everything is still as it should be, without anyone having called them. It doesn't prevent someone from coming in through a side window (that would require constant surveillance, outside this guide's scope); what it does do is guarantee that, at most a few hours after something changes without going through the front door, someone finds out.


The cron: syntax, applied to the real case

You already know the complete syntax from Module 2 (lesson 4): five fields, always in UTC, with a minimum of 5 minutes between runs. For Andes Cargo, the decision is a daily run, at a low-traffic time:

name: drift-detection

on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

"0 6 * * *" — every day, at 6:00 AM UTC. workflow_dispatch accompanies schedule, for the same reason you already saw in Module 2: it's the escape hatch for running the same check by hand, without waiting for the scheduled time — exactly what you're going to use in lesson 7.


The complete job: a read-only terraform plan

jobs:
  check-drift:
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_DEFAULT_REGION: us-east-1
      AWS_ENDPOINT_URL: http://host.docker.internal:4566
    steps:
      - name: Check out andes-cargo-infra
        uses: actions/checkout@v4

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.15.8"
          terraform_wrapper: false

      - name: Terraform init
        run: terraform init -input=false

      - name: Install tflocal
        run: pip3 install --quiet --break-system-packages terraform-local

      - name: Terraform plan (read-only drift check)
        id: drift_plan
        run: |
          set +e
          tflocal plan -input=false -no-color -detailed-exitcode | tee drift-output.txt
          echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"

      - name: Report drift status
        run: |
          {
            echo "## Drift check — andes-cargo-infra"
            echo "Triggered by: ${{ github.event_name }}"
            case "${{ steps.drift_plan.outputs.exitcode }}" in
              0) echo "Result: no drift detected. Infrastructure matches the state." ;;
              2) echo "Result: DRIFT DETECTED. Terraform found differences between the state and reality." ;;
              *) echo "Result: the plan itself failed (exit code ${{ steps.drift_plan.outputs.exitcode }}) — see the step above." ;;
            esac
          } >> "$GITHUB_STEP_SUMMARY"

Four new pieces worth understanding before running it:

-detailed-exitcode, the flag that turns a plan into a signal

Until now, every terraform plan in this guide ended with exit code 0 if it ran without errors —regardless of whether it found changes or not—. -detailed-exitcode changes that semantics into something far more useful for automating a decision:

Exit codeMeaning
0The plan ran without errors, and found no changes at all — infrastructure exactly matches what Terraform expects.
1The plan failed —a real error, syntax, connection, whatever—.
2The plan ran without errors, and did find changes — exactly the signal this job needs to decide whether there's drift.

Without this flag, there'd be no way to distinguish, by exit code, "everything's fine" from "there's a difference" — both cases would return 0.

terraform_wrapper: false, a real finding

Here's a detail verified today, running this guide, in the same spirit Module 3 (lesson 6) applied to skip_requesting_account_id. hashicorp/setup-terraform@v3 wraps, by default (terraform_wrapper: true, the implicit value if you don't write it), every terraform invocation with its own script that captures stdout/stderr/exitcode as step outputs —a real convenience, one you already used without thinking about it in Module 3—. The problem, confirmed by testing both configurations on this same job: that wrapper doesn't correctly propagate -detailed-exitcode's code 2 — it's documented behavior from the project itself (hashicorp/setup-terraform, issue #9), not an assumption made by this guide.

# With terraform_wrapper: true (the default value, WITHOUT this lesson's flag)

What to expect (literal, verified today, with the wrapper at its default value):

[drift-detection/check-drift]   ✅  Success - Main Terraform plan (read-only drift check) [18.905710542s]
[drift-detection/check-drift]   ⚙  ::set-output:: exitcode=0

exitcode=0 —even though the plan did find changes (the twelve resources to create, at this point in the project)—. The wrapper reported plain success, without distinguishing "no changes" from "changes present." With terraform_wrapper: false, the same command, on the same plan:

What to expect (literal, verified today, with terraform_wrapper: false as in this lesson's YAML):

[drift-detection/check-drift]   ✅  Success - Main Terraform plan (read-only drift check) [17.596187875s]
[drift-detection/check-drift]   ⚙  ::set-output:: exitcode=2

exitcode=2 — the correct value, the one the next step needs to genuinely report "drift detected." Without terraform_wrapper: false, this job would compile and run with no visible error at all, but would report "no drift" even when there is — the most dangerous kind of silent failure that exists in a monitoring system: one that never warns you.

${PIPESTATUS[0]}, not $?

tflocal plan ... | tee drift-output.txt is a pipe — two commands connected. $?, immediately after, reflects the exit code of the pipe's last command (tee, which almost always ends in 0, even if tflocal failed), not tflocal's. ${PIPESTATUS[0]} is a bash array that keeps each pipe command's exit code separately — [0] is, specifically, the first one: tflocal, the one that actually matters here.

set +e, so an exitcode=2 doesn't take down the job

A run:'s default shell in GitHub Actions —and in act— runs with the -e option active: any command that exits with a code other than 0 stops the script immediately. Since -detailed-exitcode uses 2 as a valid signal (not an error), set +e disables that behavior inside this specific step, so the script keeps going through the line that saves the code to $GITHUB_OUTPUT, without the job getting marked as failed just for finding a legitimate change.


Running it: the timer (representative) vs. the job (executed)

The exact same distinction you already established in Module 2 (lesson 4), now with Andes Cargo's real file.

1. The cron:'s real trigger at 6:00 AM UTC — representative, for the same reason as always. No written lesson, read at any time of day, can "wait" for a specific UTC clock time to show you a run genuinely triggered by the passage of time.

2. The job that cron: would trigger — executed, right now, with act schedule.

act schedule -W .github/workflows/drift.yml

What to expect (literal, excerpt — event_name confirms act synthesizes the same context a real schedule run would have):

[drift-detection/check-drift] ⭐ Run Set up job
[drift-detection/check-drift] ⭐ Run Main Check out andes-cargo-infra
[drift-detection/check-drift]   ✅  Success - Main Check out andes-cargo-infra [42.181834ms]
[drift-detection/check-drift] ⭐ Run Main Set up Terraform
[drift-detection/check-drift]   ✅  Success - Main Set up Terraform [3.112824208s]
[drift-detection/check-drift] ⭐ Run Main Terraform init
[drift-detection/check-drift]   | Terraform has been successfully initialized!
[drift-detection/check-drift]   ✅  Success - Main Terraform init [33.162088417s]
[drift-detection/check-drift] ⭐ Run Main Install tflocal
[drift-detection/check-drift]   ✅  Success - Main Install tflocal [3.144388042s]
[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]   ✅  Success - Main Report drift status [102.771083ms]
[drift-detection/check-drift]   ⚙  Summary - ## Drift check — andes-cargo-infra
Triggered by: schedule
Result: DRIFT DETECTED. Terraform found differences between the state and reality.
[drift-detection/check-drift] 🏁  Job succeeded

Read this result precisely, so you don't confuse it with real drift. Plan: 12 to add is the same plan as always —twelve resources to create, because on this machine a real apply against LocalStack was never completed (Module 5, lesson 4)—. The job correctly reports "DRIFT DETECTED" because there really is a difference between the state (empty) and what the HCL describes — mechanically correct, even though at this specific point in the project the cause isn't that someone changed something outside Terraform, but that no successful apply exists yet to drift away from. Lesson 7 completes the picture: what you'd see if, instead, the infrastructure did exist and someone had touched it by hand.


Common mistakes

Forgetting terraform_wrapper: false and trusting an exitcode that's always 0 (this lesson's central finding). What happens: someone copies ci.yml's hashicorp/setup-terraform@v3 (which doesn't need this flag, because it never uses -detailed-exitcode) directly into drift.yml, without adding terraform_wrapper: false. How to spot it: the job always reports "no drift detected," even when the plan's text output clearly shows changes. How to fix it: confirm drift.yml's Set up Terraform step, specifically, includes terraform_wrapper: false — it's the only real difference between ci.yml/apply.yml's hashicorp/setup-terraform and this file's.

Using $? instead of ${PIPESTATUS[0]} after a pipe with tee (syntax-based, silent). What happens: someone writes echo "exitcode=$?" >> "$GITHUB_OUTPUT" immediately after tflocal plan | tee file, expecting to capture tflocal's code. How to spot it: the captured value is almost always 0, because tee almost never fails, regardless of what tflocal did. How to fix it: use ${PIPESTATUS[0]}, the bash array that preserves each pipe command's exit code separately.

Interpreting "Plan: 12 to add" in this job as a workflow error (conceptual, revisit Module 3). What happens: someone sees drift.yml reporting "DRIFT DETECTED" and assumes something's misconfigured, because they expected to see "no drift" on a freshly created project. How to fix it: as this lesson already explained, at this specific point in the project —with no real apply ever completed— any plan, including one for a complete creation, counts as "changes present" for -detailed-exitcode. It's not a bug; it's the honest consequence of running drift detection on infrastructure that never finished applying on this machine.


Exercises

Exercise 1 — Explain -detailed-exitcode without using the word "flag." In two sentences, explain to a colleague what problem -detailed-exitcode solves that a normal terraform plan doesn't.

See solution

A complete answer sounds, roughly, like this: "A normal terraform plan succeeds (code 0) whether it finds changes or finds none at all — there's no way for an automated script to tell the two cases apart just by checking whether the command failed. -detailed-exitcode splits those two outcomes into distinct codes (0 for no changes, 2 for changes present), which lets a job like drift.yml make an automatic decision —report drift or not— without having to parse the full text of the output."

Exercise 2 — Reproduce the terraform_wrapper finding from memory. Without looking at this lesson, explain what you'd see in the exitcode reported by hashicorp/setup-terraform if you left terraform_wrapper at its default value (true) on a plan that does find changes.

See solution

You'd see exitcode=0, incorrectly — hashicorp/setup-terraform@v3's default wrapper doesn't reliably propagate -detailed-exitcode's value 2, a documented behavior (issue #9 in the project's own repository). The job would keep running "without errors" according to the wrapper, but any logic depending on distinguishing 0 from 2 —like Report drift status's case— would take the wrong branch, reporting "no drift" even when there is one.

Exercise 3 — Justify, in your own words, why drift.yml uses workflow_dispatch alongside schedule. You already saw this justification in Module 2 (lesson 4) for a generic example — now apply it specifically to Andes Cargo's case.

See solution

A complete answer sounds, roughly, like this: "If someone on the Andes Cargo team suspects a recent change in the AWS console —or, in this lab, an awslocal run by hand— might have touched something outside Terraform, it makes no sense to wait until 6:00 AM the next day to confirm it. workflow_dispatch gives anyone with access to the repository the ability to run exactly the same drift check at that moment, on demand, without touching or waiting for the scheduled cron:."


Summary and next step

In this lesson you built the complete drift.yml: on: schedule with cron: "0 6 * * *" alongside workflow_dispatch, and a job that runs a read-only terraform plan -detailed-exitcode to detect differences between the state and real infrastructure. You verified, by running both configurations, a real and documented finding —hashicorp/setup-terraform@v3 needs terraform_wrapper: false to correctly propagate code 2— and confirmed, with act schedule, that the job runs end-to-end, even though the cron:'s real timer stays, by nature, representative.

Before moving on you should be able to: explain -detailed-exitcode's three exit codes; write from memory why this job needs terraform_wrapper: false when ci.yml/apply.yml don't; and precisely distinguish "the job ran" from "the cron triggered at 6 AM" as two completely different claims.

Lesson 7 —hands-on— runs this same job with act workflow_dispatch, and shows, with the exact honesty this deserves, how a real drift would read if Andes Cargo's infrastructure truly existed.

Resources

  1. Terraform Docs — Command: plan (-detailed-exitcode) — official reference for this lesson's central flag.
  2. GitHub — hashicorp/setup-terraform, issue #9 — the documented report about the wrapper and -detailed-exitcode, the source of this lesson's finding.
  3. GitHub Docs — Events that trigger workflows: schedule — official documentation for cron:, already cited in Module 2.
  4. This guide's Module 2 (04-the-schedule-trigger-and-cron-syntax.md) — the complete cron: syntax and the distinction between "the timer" and "the job," assumed and not repeated here.