Automation & Tools

Git for Network Engineers, Part 4: Going Pro

Tony Mattke · 2026.08.18 · 15 min read

So, your runbook repo is now shared. Three of you commit to it, the playbooks get used in real change windows, and last Tuesday somebody pushed broken YAML straight to main without anyone noticing. The next ansible-playbook run blew up halfway through, halfway across the network, and the post-mortem was awkward.

The fix isn’t “be more careful.” It’s to make it impossible to land junk on main in the first place. This post turns your repo from a thing that holds files into a working production system, with guardrails that catch problems before a human has to.

SSH keys: stop typing passwords forever

The guardrails that catch the next push of broken YAML come later. First, the less glamorous piece, because you’re about to spend the rest of this post living in the repo… pushing, pulling, opening PRs, cloning it onto whatever runs your automation. Your repo is turning into infrastructure, and infrastructure gets touched by more than your laptop. Over HTTPS, every one of those touches rides a personal access token, which is fine until you’re scripting against it or standing up a new box at the worst possible moment. An SSH key fixes that once. Get auth out of your way first, then build the parts that stop bad commits.

Generate one ed25519 keypair, add the public side to your GitHub account, and you’re done forever (on this machine).

$ ssh-keygen -t ed25519 -C "tony@runbooks-demo"
Generating public/private ed25519 key pair.
Your identification has been saved in /home/tony/.ssh/id_ed25519
Your public key has been saved in /home/tony/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:g1rgeI4m9CFWNvOykSMa5yBOwmuKPn14tn3v3VbnB+U tony@runbooks-demo

The -C flag is a comment. Use anything that helps future-you remember which key this is.

(I’m running this in a sandbox directory in the demo, not the default ~/.ssh/. For your real setup, take the default location.)

Copy the public key to your clipboard:

bash
# macOS
pbcopy < ~/.ssh/id_ed25519.pub

# Linux with X11
xclip -sel clip < ~/.ssh/id_ed25519.pub

# Or just cat it and copy by hand:
cat ~/.ssh/id_ed25519.pub

Mine looks like this:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPf//JKhgCR5CLNEhbi0TwbGuP6P8cpV2FxSdsvjXKFU tony@runbooks-demo

Go to GitHub → Settings → SSH and GPG keys → New SSH key. Paste the public key (the line above, not the private file in ~/.ssh/id_ed25519. That one stays on your machine, forever).

Verify it works:

bash
$ ssh -T [email protected]
Hi tonhe! You've successfully authenticated, but GitHub does not provide shell access.

If you see that “Hi !” line, the key is wired in.

Now switch your existing repo’s remote from HTTPS to SSH:

bash
$ git remote set-url origin [email protected]:tonhe/runbooks-demo.git

$ git remote -v
origin	[email protected]:tonhe/runbooks-demo.git (fetch)
origin	[email protected]:tonhe/runbooks-demo.git (push)

git fetch, git pull, git push from this point onwards go over SSH using the key you just made. No more password prompts.

GitHub Actions: lint your YAML on every push

This is the headline upgrade for a runbook repo. Every push and every PR runs an automated check that lints your YAML and validates your playbooks. When somebody (You? The FNG? Your PHB?) breaks something, you find out from a red X in your inbox instead of from ansible-playbook exploding in a change window.

Make a workflow file at .github/workflows/lint.yml:

yaml
---
name: lint

"on":
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  yaml:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install linters
        run: |
          python -m pip install --upgrade pip
          pip install yamllint ansible-lint ansible

      - name: Install Ansible collections
        run: ansible-galaxy collection install cisco.ios cisco.asa

      - name: yamllint
        run: yamllint .

      - name: ansible-lint
        run: ansible-lint playbooks/

      - name: ansible syntax-check
        run: |
          for pb in playbooks/*.yml; do
            ansible-playbook --syntax-check -i inventory.ini "$pb"
          done

If you’ve never read a GitHub Actions file, here’s the whole model in one breath. On every push to main and every PR against it, GitHub spins up a throwaway Ubuntu machine and runs your job’s steps top to bottom. This file has one job, called yaml (that name matters later, when branch protection goes looking for it). The moment any step exits non-zero, the run fails and you get the red X.

The steps come in two flavors. uses: pulls in a prebuilt action somebody else maintains… checkout clones your repo onto that fresh machine, setup-python puts a Python on it for the linters to run under. run: is just shell, the same commands you’d type by hand. Read top to bottom it’s nothing exotic. Grab the code, install Python, install the linters, then run yamllint, ansible-lint, and a syntax check, each as its own step so a failure points at exactly which check tripped.

A couple of small gotchas worth knowing:

  • The YAML 1.1 spec treats on as a boolean, which means an unquoted on: key can be parsed as True: by some tools. GitHub Actions handles it fine, but yamllint will scream. Quote it as "on": and everybody’s happy.
  • The “Install Ansible collections” step is there because cisco.ios and cisco.asa are not part of core Ansible. If you skip that step, ansible-lint and --syntax-check both fail with “couldn’t resolve module” errors.

Commit and push:

$ git add .github/workflows/lint.yml
$ git commit -m "ci: lint YAML and ansible playbooks on push and PR"
$ git push

GitHub kicks off a workflow run as soon as it receives the push. Watch it:

$ gh run list --limit 3
completed	success	ci: install required ansible collections	lint	main	push	26265816692	50s	2026-05-22T02:58:18Z
completed	failure	ci: lint YAML and ansible playbooks on push and PR	lint	main	push	26265772981	44s	2026-05-22T02:56:51Z
completed	failure	ci: lint YAML and ansible playbooks on push and PR	lint	main	push	26265738232	0s	2026-05-22T02:55:43Z

(The first two attempts there are me fumbling the workflow file. The third one is the version above, with collections installed.)

gh run view <id> gives you the full breakdown:

$ gh run view 26265816692

✓ main lint · 26265816692
Triggered via push about 1 minute ago

JOBS
✓ yaml in 47s (ID 77308684335)

For watching a run live as it executes, gh run watch <id> blocks until the run completes and prints the result. Handy for git push && gh run watch $(gh run list -L 1 --json databaseId -q '.[0].databaseId') one-liners.

Now let’s prove it catches things. I made a branch with deliberately broken YAML and pushed it:

$ git switch -c break-yaml
$ # Add a playbook with bad indentation
$ git add playbooks/intentionally-broken.yml
$ git commit -m "Add broken playbook to demonstrate CI catching it"
$ git push -u origin break-yaml

$ gh pr create --title "Add broken playbook" --body "This intentionally breaks YAML to show CI catching it."
https://github.com/tonhe/runbooks-demo/pull/2

A few seconds later, the PR has a failed check on it:

$ gh pr checks 2
yaml	fail	51s	https://github.com/tonhe/runbooks-demo/actions/runs/26265858147/job/77308809727

Looking at what failed:

$ gh run view 26265858147 --log-failed | grep yamllint | head -15
yaml	yamllint	./playbooks/intentionally-broken.yml
yaml	yamllint	##[error]6:29 syntax error: mapping values are not allowed here (syntax)

Line 6, column 29, in the file I just pushed. The PR can’t be merged until that’s fixed, which we’ll lock down for real in the next section.

Branch protection: stop bad pushes from landing

Right now nothing actually prevents you (or anyone with write access) from pushing straight to main. The Actions workflow runs and fails, but the bad commit is already on main. The fix is branch protection rules. They tell GitHub, in effect, “for this branch, the rules are X, and I want them enforced.”

Think of it as the Bridge of Death. Before anyone crosses over to main they answer the Bridgekeeper. What’s the status check, did it pass, did somebody approve. Get one wrong and you don’t get a talking-to, you get flung into the Gorge of Eternal Peril. The merge button just stays gray.

You can configure branch protection through the GitHub web UI under Settings → Branches. We’re going to do it via the API, because it’s reproducible and you can version-control the rules.

Build a JSON file describing what you want:

json
{
  "required_status_checks": {
    "strict": true,
    "contexts": ["yaml"]
  },
  "enforce_admins": false,
  "required_pull_request_reviews": {
    "required_approving_review_count": 1,
    "dismiss_stale_reviews": true
  },
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false
}

What each piece does:

  • required_status_checks.contexts: ["yaml"]: the yaml job from our workflow has to pass before the branch can be merged. (The name matches the jobs.yaml block in the workflow YAML.)
  • required_status_checks.strict: true: the PR branch has to be up to date with the latest main before merging. Forces people to integrate fresh changes before shipping.
  • required_pull_request_reviews.required_approving_review_count: 1: somebody other than the author has to approve the PR.
  • dismiss_stale_reviews: true: if you push new changes after getting an approval, the approval is dismissed and you need a fresh one.
  • allow_force_pushes: false and allow_deletions: false: nobody can rewrite or delete main, even from the CLI.
  • enforce_admins: false: admins (you) can override these rules in an emergency. Set to true if you want zero exceptions.

Send it:

$ gh api -X PUT repos/tonhe/runbooks-demo/branches/main/protection \
       --input branch-protection.json

The response is a chunky JSON object. Pluck out the parts you care about to confirm:

$ gh api repos/tonhe/runbooks-demo/branches/main/protection \
     -q '{required_status_checks: {strict: .required_status_checks.strict, contexts: .required_status_checks.contexts},
          required_approvals: .required_pull_request_reviews.required_approving_review_count,
          dismiss_stale_reviews: .required_pull_request_reviews.dismiss_stale_reviews,
          allow_force_pushes: .allow_force_pushes.enabled,
          allow_deletions: .allow_deletions.enabled}'
{"allow_deletions":false,"allow_force_pushes":false,"dismiss_stale_reviews":true,"required_approvals":1,"required_status_checks":{"contexts":["yaml"]
,"strict":true}}

Rules are live. From this moment on, main is read only except through a passing PR with approval.

One real thing to know. With enforce_admins: false, you can still push directly to main yourself (you are the admin), but GitHub logs the bypass in the audit trail:

$ git push
remote: Bypassed rule violations for refs/heads/main:        
remote: 
remote: - Changes must be made through a pull request.        
remote: 
remote: - Required status check "yaml" is expected.

GitHub logs the warning, but the push goes through anyway. If you want to stop admin pushes too, flip enforce_admins: true.

Heads up for solo work: the “required approving review count of 1” requirement means you can’t approve your own PRs. If you’re working alone, set it to 0, or skip the required_pull_request_reviews block entirely. The other rules (status checks, no force-push) still buy you a lot. For team work, 1 approval is the sane default.

Pre-commit hooks: stop bad commits before they exist

The Actions workflow catches mistakes on the server. Pre-commit hooks catch them on your laptop, before the commit even gets to a state where you could push it. Less waiting, much faster feedback.

The pre-commit framework is the tool everyone uses. It runs configurable hooks against your staged files every time you git commit, and refuses to let the commit go through if anything fails.

Install it:

bash
pip install pre-commit

Make a config file at the root of the repo, called .pre-commit-config.yaml:

yaml
---
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: check-added-large-files
        args: ["--maxkb=500"]
      - id: end-of-file-fixer
      - id: trailing-whitespace

  - repo: https://github.com/adrienverge/yamllint
    rev: v1.38.0
    hooks:
      - id: yamllint
        args: ["-d", "{extends: default, rules: {line-length: {max: 160}}}"]

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks

What each hook catches:

  • check-added-large-files: refuses to commit anything over 500KB. Stops you from accidentally checking in a binary, a packet capture, or that 800MB router image.
  • end-of-file-fixer and trailing-whitespace: quality of life cleanup. Auto-fixes files to end with a newline and removes trailing spaces.
  • yamllint: same linter we run in CI, except now it runs before you can commit.
  • gitleaks: scans for credential patterns. Refuses to let you commit anything that smells like an API key.

Install the hooks into the repo:

$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

That writes a script into .git/hooks/pre-commit that runs your configured hooks. From now on, every git commit in this repo will run them first.

One thing to be clear about, because it burns people. This only applies to your machine. The .pre-commit-config.yaml file is committed and travels with the repo, but .git/hooks/ is not tracked by git and never leaves your clone. When a teammate clones the repo, they get the config and zero active hooks until they run pre-commit install themselves. Nobody’s protected by default, and the failure is silent… their commits just sail through with none of the checks running. This is exactly why the GitHub Actions check earlier isn’t optional. The local hooks are fast feedback for whoever bothered to set them up. The server side workflow is the backstop that runs for everyone, on every push, no matter what their laptop is or isn’t configured to do.

Run them once against everything to make sure your existing files pass:

$ pre-commit run --all-files
check for added large files..............................................Passed
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
yamllint.................................................................Passed
Detect hardcoded secrets.................................................Passed

Now let’s prove it works the way we want. I tried to commit the same fake AWS credential from Part 2 to see what happens:

$ git add group_vars/cloud.yml
$ git commit -m "Add cloud creds again because I did not learn"
check for added large files..............................................Passed
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
yamllint.................................................................Passed
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1

Finding:     aws_access_key_id: REDACTED
Secret:      REDACTED
RuleID:      aws-access-token
Entropy:     3.784184
File:        group_vars/cloud.yml
Line:        2
Fingerprint: group_vars/cloud.yml:aws-access-token:2

leaks found: 1

Commit aborted. The hook does its best Dennis Nedry… ah ah ah, you didn’t say the magic word. Except this time the lockout is on your side, and what it’s keeping out of the repo is the AWS key you just tried to commit for the second time. The file isn’t committed, the secret never enters the repository’s history, and I get a clear pointer to exactly where the problem was. This is the version of the Part 2 scrubbing exercise that you never have to do, because the commit never happened in the first place.

(If you ever do need to override a hook and commit anyway, git commit --no-verify skips them. Don’t do this. The whole point is the hooks. If a hook is wrong, fix the hook.)

CODEOWNERS: auto-route PR reviews

Branch protection requires one approving review before a merge. CODEOWNERS tells GitHub which humans should be auto-requested for that review, depending on which files the PR touches.

Make .github/CODEOWNERS:

# Default reviewer for everything
* @tonhe

# Firewall playbooks need a second pair of eyes
playbooks/firewall*.yml @tonhe @network-team

# CI changes need ops review
.github/workflows/ @tonhe

The syntax is gitignore-style globs on the left, GitHub usernames or team handles on the right. The most specific match wins, so a PR touching playbooks/firewall-policy.yml auto-requests reviews from @tonhe and @network-team, not just the default @tonhe.

Commit it, push it, and from the next PR onwards, GitHub’s “Reviewers” field on PR pages fills in automatically based on the rules in this file.

This pairs neatly with branch protection’s “require review” rule. When somebody opens a PR that touches firewall playbooks, the network team gets a notification, somebody on that team approves, the status check passes, the merge button activates. No process docs, no Slack pings, no “hey can you look at this.”

Going deeper with gh

If you’re going to live in the terminal, here’s the rest of gh that makes it worth it.

List PRs:

$ gh pr list --state all --limit 5
2	Add broken playbook	break-yaml	CLOSED	2026-05-22T02:59:39Z
1	Add edge firewall baseline ACL playbook	add-firewall-playbook	MERGED	2026-05-22T02:40:15Z

Check out somebody else’s PR locally (this is the killer feature for Ansible work):

bash
gh pr checkout 7

That clones the PR’s branch into your working directory, sets up tracking, and switches you to it. You can now ansible-playbook --check --diff against the PR’s playbook against your inventory, see what it would do, and approve based on real behavior instead of diff reading.

Approve a PR from the CLI:

bash
gh pr review 7 --approve --body "Tested in lab. Diff looks right."

List recent workflow runs and watch one:

$ gh run list --limit 5
in_progress		Add CODEOWNERS	lint	main	push	26266024387	5s	2026-05-22T03:05:00Z
in_progress		Wire up pre-commit (yamllint, gitleaks, large-file guard)	lint	main	push	26266018185	17s	2026-05-22T03:04:48Z
completed	failure	Add broken playbook	lint	break-yaml	pull_request	26265858147	54s	2026-05-22T02:59:42Z
completed	success	ci: install required ansible collections	lint	main	push	26265816692	50s	2026-05-22T02:58:18Z
completed	failure	ci: lint YAML and ansible playbooks on push and PR	lint	main	push	26265772981	44s	2026-05-22T02:56:51Z

$ gh run watch 26266024387

watch blocks until the run finishes, then prints the result. The terminal equivalent of refreshing the Actions tab.

Rerun a failed run (useful when the failure was an upstream blip, not your code):

bash
gh run rerun 26266024387 --failed

--failed reruns only the failed jobs. Without it, the whole workflow runs from scratch.

Open the current repo or PR in your browser when you need the visual:

bash
gh repo view --web
gh pr view --web 7

These are the ones I use multiple times a day. The full gh help is worth skimming once you’re comfortable.

Where to go from here

You can do work, recover from mistakes, and run the repo like a small piece of production infrastructure. That’s the whole curriculum I set out to teach. A few directions worth pointing at if you keep going:

  • Tags and releases. When you reach a known good state of your runbooks (start of quarter, post-audit, just before a freeze), tag it: git tag -a v1.0 -m "Q2 baseline", git push --tags. GitHub will offer to create a “Release” from any tag, which gives you a permanent labelled snapshot for rollback or auditor questions.

  • Issues and Projects. GitHub’s built-in issue tracker is genuinely good for tracking “I need to write the playbook for X” or “this runbook broke on platform Y.” The Projects view lets you turn issues into a kanban board if that’s your thing.

  • Signed commits. If you’re in a regulated environment, set up GPG or SSH commit signing. Combined with branch protection requiring signed commits, you get cryptographic proof that every change came from a verified key.

  • GitOps for network state. Once your runbooks live in git, the obvious next step is to treat the desired state of the network the same way. Push intended config to a repo, have a pipeline apply it (with rollback on failure). Tools like Nornir, NetBox, and your own automation glue can take you there.

  • Stack the hooks deeper. Pre-commit’s hook catalog is huge. There are hooks for terraform fmt, Python black/ruff, shellcheck, markdownlint, you name it. As the repo grows beyond runbooks, the hook config grows with it.

There’s a lot more in git itself (worktrees, bisect, submodules, sparse-checkout) that comes up only in specific situations. The Pro Git book at git-scm.com/book is the canonical reference and stays free forever. Read whichever chapter applies the day you need it.

Four posts, one runbook repo, from git init to a workflow you can run like production. Every command from all four parts lives on the printable git cheatsheet, the page I keep open in another tab. I’ll keep adding to this series as new tricks and updates earn their spot, so treat this as a foundation rather than a finish line. Go ship something.

More in Automation & Tools

Related Posts

Automation & Tools · 24 min read

Git for Network Engineers, Part 2: The Oh-Shit Toolkit

2026.06.30

You’ve had git in your daily workflow for a few weeks. You’ve got the basics down, until you do one of these… You committed vault_password.txt and pushed it before your second coffee.