Skip to main content
RunBook Academy

AnsibleXXXVIII · Git Workflow and CI for AnsibleAutomation as production code

Branch protection and what production runs from

Advanced⏱ ~18 mingitansible-core

What you'll learn

  • Configure branch protection that reflects the blast radius of the paths it guards
  • Explain why a scheduled run against a branch head has no version
  • Pin a production run to a tag or commit and record which one ran
  • Identify the routes by which protection is legitimately and illegitimately bypassed

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Branch protection is the part of this lesson everybody already agrees with, so it goes first and briefly. The second half is the part that gets skipped, and it is the one that causes 02:00 incidents.

The uncontroversial half

main is protected. No direct pushes, no force pushes, no branch deletion. Changes arrive through pull requests.

Branches are short-lived. A branch open for three weeks accumulates merge conflicts and stops being reviewable, and the review is the control. Days, not weeks.

Review is required, and CODEOWNERS decides who. Lesson 7 of the previous part covers scoping it to the paths that can cause outages rather than to everything.

Status checks are required. The pipeline must pass before merge, and “required” has to mean it — an override anybody can click is a suggestion.

None of that is Ansible-specific. What is Ansible-specific is that the paths worth protecting are not the ones an application repository would choose. inventories/production/ matters more than most roles, because a change there re-scopes runs rather than changing behaviour, and it will be a two-line diff.

A branch head is not a version

Here is the half that gets skipped.

Suppose the production job does this:

git checkout main
git pull
ansible-playbook -i inventories/production playbooks/site.yml

What does production run? Whatever main contained at the moment the job started. That is not a version — it is a timestamp, and it has three consequences that all show up at once during an incident.

Nobody can say what production is running. “The latest main” is true and useless. The commit that ran at 02:00 is not the commit at the top of main now.

A merge changes production without anyone deciding to. Somebody merges a reviewed, tested, entirely correct change on Friday afternoon. It applies itself on Sunday at 02:00, unattended, because that is when the job runs. Nothing was wrong with the change; the problem is that nobody chose the moment.

Two runs of “the same thing” are not the same thing. A run that started before a merge and a run that started after it are different runs of a job with one name.

The fix is one line:

RELEASE=2026.08.11-1
git checkout "refs/tags/$RELEASE"
ansible-playbook -i inventories/production playbooks/site.yml \
  -e "release_identifier=$RELEASE"

Now merging is not deploying. Tagging is deploying, and tagging is a deliberate act with a name attached to it.

Which commit ran, recorded where the answer is findable

Pinning the run is half of it. Being able to answer which pin ran three weeks later is the other half.

The provenance play from the previous part carries release_identifier into the run output for exactly this reason:

Read-only / Safethe release identifier in the run output
$ ansible-playbook -i inventories/production playbooks/_provenance.yml -e release_identifier=2026.08.11-1
TASK [Publish it] **************************************************************
ok: [web-prod-01.example.com] => {
  "run_manifest": {
      "ansible_version": "2.21.3",
      "config_file": "/srv/estate/ansible.cfg",
      "inventory_sources": [
          "/srv/estate/inventories/production"
      ],
      "play_hosts": 2,
      "release": "2026.08.11-1"
  }
}

The failure this prevents is specific. Without it, the run log records that a job called deploy-production succeeded, and reconstructing what that job actually ran means correlating a timestamp with the repository history — which works until somebody force-pushes, rewrites history to remove a secret, or deletes the branch.

UNTAGGED in that field is itself a useful signal. A production run without a release identifier is a run somebody started by hand, and knowing that is often the answer to the question you are asking.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A scheduled job runs git checkout main && git pull && ansible-playbook against production every night. What is the core problem?

  2. Q2. Which of these keep a tag-based production deploy trustworthy? Select all that apply.

  3. Q3. ansible-pull with no --checkout argument runs the repository default branch, so hosts polling at different times can be running different code.

  4. Q4. Emergency bypasses of branch protection have risen to roughly one a week. What does that most likely indicate?

Passing score: 75%. Answers are checked in this browser.