Module 6: Rollback And Safety Nets

4. Branch protection as a control

Description

Everything you've built so far assumes something no YAML file guarantees on its own: that a change to main always goes through a Pull Request first, and that ci.yml runs before that PR can merge. Without anything else, that's just a convention — anyone with write permission on the repository could, technically, git push directly to main, skipping ci.yml entirely. This lesson teaches the control that turns that convention into a rule impossible to skip: branch protection, configured directly in GitHub, not in any workflow.

Connection to the module

Lessons 2 and 3 assumed, without saying it explicitly, that every change goes through a reviewed Pull Request. This lesson makes that assumption explicit and turns it into a real control: branch protection is the piece that forces it to be that way, always, without depending on every team member remembering to follow the process. Lesson 5 revisits the Claude Code incident with this piece already in hand, assessing whether branch protection —combined with what you've already built— would have changed anything.


Analogy: the access-controlled door to the server room

Think about a real server room. Any employee at the company could, in theory, walk up to the door —nothing in the building physically stops them—. What makes that room secure isn't that every employee is trustworthy (even though they are): it's that the door has an access-controlled lock, which only opens for authorized badges, and which logs who came in and when. Trust doesn't depend on everyone's goodwill — it depends on a mechanism that makes the bad decision, or the mistake, physically impossible without going through the control.

main, without branch protection, is the server room with no lock: ci.yml can be the best pipeline in the world, but if someone can git push directly to main without going through it, the entire pipeline is a recommendation, not a control. Branch protection is the lock: it turns "everything is supposed to go through a PR" into "there's no technical way for it not to."


What it is, precisely

Branch protection is a set of rules GitHub applies to a repository's specific branch —typically main—, configured in Settings → Branches, not in any file in the repository. The two most relevant rules for the pipeline you built in this guide:

  • Require a pull request before merging — nobody can push directly to the protected branch. Every change has to arrive through a Pull Request, no exceptions, regardless of that person's permission on the repository (except, optionally, administrators, who can be explicitly exempted — a configuration decision, not a default).
  • Require status checks to pass before merging — a Pull Request's "Merge" button stays disabled until the checks you choose —in this case, ci.yml's terraform-checks job— finish green. If ci.yml fails, GitHub doesn't allow merging, no matter how much the person who opened the PR wants to do it anyway.

Together, these two rules are the piece everything you built was missing: ci.yml can calculate the most complete plan in the world, but without branch protection, nothing forces that calculation to happen before the change reaches main.


The exact click-path (representative)

This is repository configuration, not a workflow — there's no way for act to execute it, whatever YAML you write. What follows is the real, documented path, as it looks today on GitHub.com:

   1. Repository → Settings → Branches
                        │
                        ▼
   2. "Branch protection rules" → Add rule
                        │
                        ▼
   3. Branch name pattern: main
                        │
                        ▼
   4. ☑ Require a pull request before merging
        └─ optional: require a minimum number of approvals (e.g., 1)
                        │
                        ▼
   5. ☑ Require status checks to pass before merging
        └─ search for and select: terraform-checks (ci.yml's job)
        └─ optional: ☑ Require branches to be up to date before merging
                        │
                        ▼
   6. Create (or Save changes)

From that moment on, any attempt at git push directly to main —without going through a Pull Request— gets rejected by GitHub, with an explicit message stating the branch is protected. And any Pull Request whose terraform-checks check hasn't finished green shows the merge button disabled, with a label indicating which check is missing or failed.

Why the check's name has to match exactly: GitHub identifies a status check by the job's name, not the complete workflow's — in ci.yml, that name is terraform-checks (the identifier you declared under jobs: since Module 3). If you renamed that job without updating the branch protection rule, GitHub would keep waiting for a check that no longer exists, and no PR could ever merge — a real, common configuration mistake, explicitly named in this lesson's mistakes section.


Why this is exactly what the pipeline was missing

It's worth connecting this, precisely, to every piece you've already built:

Without branch protectionWith branch protection
ci.yml runs on every PR, but nothing stops merging a PR with ci.yml redThe merge button stays disabled until terraform-checks finishes green
Someone can git push directly to main, with no PR, no reviewGitHub rejects any direct push to the protected branch
apply.yml (triggered by push to main) could run on a change nobody reviewedapply.yml can only be triggered by a push that, thanks to branch protection, always arrived through a merged PR

This last point deserves underlining: apply.yml itself doesn't verify that the push triggering it came from a reviewed PR —the on: push: branches: [main] trigger (Module 5, lesson 2) reacts to any push to that branch, regardless of its origin—. It's branch protection, not apply.yml, that guarantees the only way to produce that push is by merging a Pull Request that already went through ci.yml. Without branch protection, all of ci.yml/apply.yml's careful design would be, technically, skippable.


Common mistakes

Thinking branch protection is a repository file, like ci.yml (conceptual, this lesson's central mistake). What happens: someone looks for a .github/branch-protection.yml file or similar inside andes-cargo-infra/. How to spot it: if you look for branch protection inside the repository's code instead of in GitHub.com's Settings. How to fix it: branch protection lives in the repository's configuration on GitHub, managed via the web interface (or the GitHub API, outside this guide's scope) — never as a versioned file inside the repository itself.

Configuring the check's name incorrectly (configuration-based, very common in real practice). What happens: someone writes the workflow's name (ci) instead of the job's name (terraform-checks) when selecting the required status check, and GitHub never finds a check with that exact name — the PR stays blocked forever, with no "pending" check that ever resolves. How to spot it: if the merge button stays disabled even after ci.yml finishes green, check the exact name configured in Settings → Branches against ci.yml's jobs:. How to fix it: the required name has to match the job's identifier (terraform-checks), not the filename or the complete workflow's name:.

Assuming branch protection replaces lesson 7's guardrail (scope-based, revisit lesson 1). What happens: someone thinks that, with branch protection active, no additional control over the plan's content is needed anymore. How to fix it: branch protection guarantees the process (reviewed PR, green checks) — it doesn't look at whether the plan itself is dangerous. A perfectly reviewed and approved PR, with ci.yml green, could still contain a change that destroys Shipments, if nobody noticed while reading the plan. Lesson 7's guardrail is the layer that does look at the content; branch protection is the layer that guarantees that content went through review before reaching main. They're complementary, not substitutes.


Exercises

Exercise 1 — Explain why act can't execute this lesson, in your own words. To a colleague who asks "why didn't we test this with act, like everything else?".

See solution

A complete answer sounds, roughly, like this: "act simulates running workflows —YAML files inside .github/workflows/—, running their jobs in local Docker containers. Branch protection isn't a workflow: it's a configuration that lives on GitHub.com's servers, applied to how you can interact with a repository branch (who can push, which checks are mandatory before merging). There's no YAML act can read or execute for this — the only way to see it in action is to have a real repository on GitHub.com, with this configuration turned on, and try to merge (or push directly) against it."

Exercise 2 — Diagnose a blocked PR. A colleague sets up branch protection, but after ci.yml finishes green, the merge button stays disabled, showing "Expected — Waiting for status to be reported." Based on this lesson, what configuration mistake is most likely?

See solution

Most likely, the required status check's name, configured in Settings → Branches, doesn't exactly match ci.yml's job identifier (terraform-checks) — maybe the workflow's name (ci) was written instead, or with a different capitalization, or an extra space. GitHub keeps "waiting" for a check with the exact name you configured, and since no job produces that exact name, the status never resolves, no matter how many times ci.yml runs successfully.

Exercise 3 — Design Andes Cargo's policy. Based on everything you've learned in this module so far, write, in two or three sentences, what exact branch protection configuration you'd recommend for Andes Cargo's real repository, and why.

See solution

A reasonable recommendation: turn on "Require a pull request before merging" with at least one required approval (so the human review of the plan, established since Module 3, is mandatory and not just a best practice); turn on "Require status checks to pass before merging," specifically requiring the terraform-checks check; and consider "Require branches to be up to date before merging" so no PR merges with a plan calculated on an old version of main — exactly the same kind of inconsistency risk Module 5 (lesson 5) already named for the state, now applied to the branch itself.


Summary and next step

In this lesson you learned branch protection: the repository configuration —not a workflow— that turns "everything is supposed to go through a reviewed PR" into a technical rule impossible to skip, with two central rules (mandatory PR, mandatory checks before merging) and the exact click-path to turn them on. You precisely confirmed why this layer is necessary even with ci.yml/apply.yml already built: without it, nothing stops a direct push to main from triggering apply.yml on a change nobody reviewed.

Before moving on you should be able to: explain the difference between branch protection's two central rules; diagnose a PR blocked by a misconfigured check name; and articulate why branch protection and lesson 7's guardrail are complementary layers, not interchangeable ones.

Lesson 5 uses this piece —together with everything else in the module— to answer, with the depth Module 1 left pending, this guide's central question: would a pipeline have stopped the Claude Code incident?

Resources

  1. GitHub Docs — About protected branches — complete official documentation for branch protection, including both of this lesson's rules.
  2. GitHub Docs — Managing a branch protection rule — the exact click-path, step by step, with official screenshots.
  3. This guide's Module 5 (02-from-plan-to-apply-the-merge-trigger.md) — the on: push: branches: [main] trigger branch protection finishes securing.
  4. This guide's Module 3 (02-the-hashicorp-github-pattern.md) — the plan on PR / apply on merge pattern branch protection turns into a technical rule, not just a convention.