By the end you'll know
- The questions auditors ask about every change, and where the answers already live
- How to read Git history as evidence of who changed what, and when
- What separation of duties means, and how branch protection enforces it
- How Terraform plans and drift checks prove production matches the agreed baseline
- How GitOps and scheduled jobs turn evidence into something that collects itself
Where you are
Lesson 3 named change management, CM-2 and CM-3, as the family where engineers have the biggest advantage. This lesson shows why. Teams that work through Git, pull requests, Terraform and GitOps already produce most of the evidence an auditor wants for change control, as a side effect of working normally. The trick is knowing where it is, and closing the few gaps that tools don't close for you.
A bank statement, not a shoebox of receipts
Imagine proving where your money went last year. One way is a shoebox of paper receipts, some missing, some faded, sorted the night before. The other is your bank statement: every transaction, automatically recorded, with the date, the amount and who it went to, and nobody could quietly edit it afterwards.
Manual change evidence is the shoebox: screenshots, spreadsheets updated by hand, emails that say "approved", gathered in a rush before the audit. Git is the bank statement. Every change is recorded as it happens, with who made it, when, exactly what changed, and, with pull requests, who reviewed and approved it. You don't collect this evidence; you just stop throwing it away.
What auditors ask about a change
For change control, an auditor picks a sample of changes to production, and for each one asks roughly the same six questions. Here's where each answer already lives:
| The auditor asks | Where the answer lives |
|---|---|
| Who made the change? | The commit's author |
| What exactly changed? | The commit's diff, line by line |
| When? | The commit and merge timestamps |
| Was it reviewed and approved by someone else? | The pull request's reviews |
| Was it tested before it went live? | The checks that ran on the pull request |
| Does production actually match what was approved? | Terraform's plan, and GitOps sync status |
The rest of this lesson takes those in order.
Git: who, what and when
Any Git repository is already a change log. Here's the repository for this website, the five most recent changes:
git log --format='%h %ad %an %s' --date=short -5
--format picks what to show for each commit: %h is its short ID, %ad the date, %an the author's name and %s the summary line. Run it in any repository you work in. Here's this site's:
f9cdd96 2026-10-25 Mayowa Babatola Compliance Lesson 3: controls in engineering terms, with the control sorter game (#71)
c4ffe69 2026-10-25 Mayowa Babatola Compliance Lesson 2: the frameworks in job descriptions, and rename the scenario sections (#70)
5a0a7cd 2026-10-25 Mayowa Babatola Compliance track: new topic, and Lesson 1: what compliance is (#69)
407f15b 2026-10-25 Mayowa Babatola Networking Lesson 12: troubleshooting checklist, with the error decoder widget (#68)
d15547b 2026-10-25 Mayowa Babatola Networking Lesson 11: networking in Kubernetes, and fix the CivicGrid callouts in 7 and 10 (#67)
Who, when, and what in a sentence, with a pull request number to follow. An auditor usually asks about one file or system, so you can narrow it down. Add -- and a path to see the history of just one file:
git log --format='%h %ad %an %s' --date=short -3 -- content/resources.yaml
5a0a7cd 2026-10-25 Mayowa Babatola Compliance track: new topic, and Lesson 1: what compliance is (#69)
2fae0cb 2026-10-24 Mayowa Babatola Networking track: 12 lessons, and publish Lesson 1 (#57)
7b82721 2026-10-24 Mayowa Babatola Publish Terraform Lesson 2 and add the loops and conditionals lesson to the track (#49)
Every change ever made to that file, by whom and when. And for any one change, git show --stat says exactly which files it touched:
git show --stat --format='%h %an %ad%n%s' --date=iso c4ffe69
c4ffe69 Mayowa Babatola 2026-10-25 00:55:20 -0400
Compliance Lesson 2: the frameworks in job descriptions, and rename the scenario sections (#70)
app/blog/mdx-components.js | 2 +
app/components/FrameworkFinder.js | 89 ++++++++
app/globals.css | 18 ++
content/lessons/compliance/the-frameworks.mdx | 247 ++++++++++++++++++++++
content/lessons/compliance/what-compliance-is.mdx | 2 +-
5 files changed, 357 insertions(+), 1 deletion(-)
Drop --stat and you get every changed line. That's CM-3's "what exactly changed" answered to the character, and it's the same for code, Terraform and Kubernetes files, as long as they live in Git. Anything that changes outside Git is a gap in this record, which is the first case in Spot the problem below.
Pull requests: reviewed and approved
Git records who changed something. The pull request records the part auditors care about most: that someone else looked at it and agreed before it went live. With the GitHub command-line tool, you can pull a pull request's record as data:
gh pr view 71 --json number,author,mergedBy,mergedAt,reviews
It returns the author, who merged it, when, and the list of reviews: who approved, and when. Saved alongside the commit, that's a complete change record, straight from the source.
The principle behind it has a name: separation of duties, sometimes called the four-eyes principle. The person who writes a change shouldn't be the only person who approves it, so that no single person, by mistake or on purpose, can push something into production alone. Frameworks ask for it directly; in NIST 800-53 it's AC-5.
Git hosts can enforce it, instead of just hoping. Branch protection rules on the main branch can:
- Require a pull request, so nobody pushes to the main branch directly.
- Require at least one approval from someone other than the author before merging.
- Require checks to pass, such as tests or a Terraform plan, which is the "was it tested?" answer.
- Apply the rules to admins too, so the most powerful accounts can't quietly skip them.
The setting itself becomes evidence: a screenshot, or better, the branch protection rules exported as data, show the control is enforced, not just promised.
Terraform: the baseline, in writing
Terraform answers the questions Git can't on its own: what should production look like, and does it?
The Terraform files are your baseline configuration, CM-2 from Lesson 3: the agreed, approved state of the infrastructure, in writing. And terraform plan is a preview of a change before it happens, which makes it ideal evidence: many teams post the plan's output on the pull request, so the reviewer approves the actual change, not just the code.
Plan has one more trick that's worth knowing for compliance. With -detailed-exitcode, its exit code says whether production matches the code:
terraform plan -detailed-exitcode
echo $?
echo $? prints the exit code of the command before it. There are three possible answers:
| Exit code | Means |
|---|---|
0 | Production matches the code. No changes. |
1 | The plan itself failed, like a typo or missing credentials. |
2 | Production and the code differ: something changed that the code doesn't know about, or a code change hasn't been applied. |
A difference between the code and what's really running is called drift. Run that check on a schedule, alert on exit code 2, and you have a detective control for unapproved changes: anyone who changes production by hand in the cloud console shows up the next morning. Keep the daily results, and you have evidence that the baseline held, every day of the audit period.
GitOps: production that can't wander off
Argo CD, from the Argo CD tile, takes this one step further for Kubernetes. It constantly compares the cluster with Git, shows whether each app is Synced or OutOfSync, and keeps a history of every sync: which commit was deployed, and when.
With self-heal turned on, it goes further still: if someone changes something in the cluster by hand, Argo CD notices it no longer matches Git, and puts it back. That turns CM-3 from a rule people follow into a rule the system enforces. The only way to change production is to change Git, and the only way to change Git is a reviewed pull request.
Evidence that collects itself
The best evidence isn't gathered before an audit; it's gathered every day, by a machine, whether anyone remembers or not. Remember the SC-8 check from Lesson 1? Here's the same idea as a scheduled job in GitHub Actions, to read rather than run:
name: daily-evidence
on:
schedule:
- cron: "17 6 * * *"
jobs:
sc8:
runs-on: ubuntu-latest
steps:
- name: Check encryption in transit
run: |
{
echo "Control: SC-8"
echo "Checked: $(date -u)"
echo "Plain HTTP: $(curl -s -o /dev/null -w '%{http_code}' http://api.example.com/)"
echo "HTTPS: $(curl -s -o /dev/null -w '%{http_code}' https://api.example.com/)"
} > evidence-sc8.txt
- uses: actions/upload-artifact@v4
with:
name: evidence-sc8
path: evidence-sc8.txt
Every morning at 06:17 UTC, it runs the same check and keeps the result as a stored file, from the cron schedule in Python Lesson 8. Add the Terraform drift check as a second job, and a few more like it, and an audit becomes "here are the daily results for the whole period" instead of a week of screenshots. It's also where the industry is heading: FedRAMP 20x, from Lesson 2, is built around exactly this kind of automated, continuous evidence.
In my real setup
Every change to this website and to CivicGrid's cluster goes through Git and a pull request, and Argo CD deploys the cluster from Git, so most of the change record already exists. But look at the Git output above honestly: every author is me, and I merge my own pull requests. There's no second reviewer, because I work alone. An auditor would mark that as a separation-of-duties gap, AC-5, and they'd be right. Solo projects can't fully close it; what they can do is add compensating controls, like required automated checks before merging and a drift check, and say plainly where the gap is. Lesson 7 does exactly that.
Spot the problem
Three changes that leave the record weaker than it looks. Work out the gap before you read the explanation.
The quick fix in production. At 2am an app is failing, and an engineer fixes it with kubectl edit, changing a setting directly in the cluster. It works. Nobody updates Git. What's wrong? The change has no pull request, no review and no record, so CM-3 wasn't followed, and the next audit sample might land on it. Worse, with Argo CD self-heal, the fix is quietly undone at the next sync, and the outage comes back. Emergencies happen, so most teams allow an emergency change process: fix it by hand if you must, then put the same change through Git straight afterwards, with the incident linked.
Approved in 30 seconds. Every pull request in a team's repository has an approval. An auditor looks closer: most were approved by the same person, within a minute of being opened, on pull requests hundreds of lines long. What's the problem? The approvals are there, but they don't show a real review: this is "rubber-stamping". Separation of duties only works if the second person actually looks. Signs that a reviewer did, like comments, questions and requested changes, are evidence too.
The console click. A team manages its cloud network with Terraform, and every change goes through a pull request. One evening, someone opens a firewall port in the cloud console to test something, and forgets to close it. A month later, the daily drift check is still returning exit code 2, and nobody's watching it. What failed? The baseline did its job, and the drift check found the change. But a detective control only works if someone acts on what it detects. An alert that nobody reads is the same as no alert at all. Drift should page someone, or open a ticket, every time.
Where you are now
You know the six questions auditors ask about a change, and where Git, pull requests, Terraform and GitOps already answer them. You know separation of duties, and how branch protection enforces it. You can use Terraform's exit codes to check for drift, and you've seen how a scheduled job collects evidence every day. Lesson 5 goes one step further: instead of only recording what happened, it checks the rules automatically before anything reaches production, with policy as code.
Check yourself
Why is Git history good evidence for change management?
What does separation of duties mean for a code change?
terraform plan -detailed-exitcode returns 2. What does that mean?
With Argo CD self-heal on, what happens to a change made by hand in the cluster?
An engineer fixes production at 2am with kubectl edit. What should happen next?
A drift check has returned exit code 2 every day for a month. What's the real failure?
Key terms
- Change record: the evidence for one change: who, what, when, reviewed by whom, tested how.
- Separation of duties (AC-5): no one person can make and approve a change alone. Also called the four-eyes principle.
- Branch protection: rules on a Git branch that require pull requests, approvals and passing checks before merging.
- Baseline configuration (CM-2): the agreed state of the infrastructure, written as code.
- Drift: a difference between what's running and what the code says should be running.
- Self-heal: Argo CD putting the cluster back to match Git when someone changes it by hand.
- Emergency change: a fix made outside the normal process, then recorded through it straight afterwards.
- Continuous evidence: evidence collected automatically, on a schedule, instead of gathered before an audit.
Go deeper
- GitHub's documentation on protected branches (opens in a new tab), and every rule you can require.
- Argo CD's guide to its automated sync policy (opens in a new tab), including self-heal.