Git for Network Engineers, Part 4: Going Pro
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-demoThe -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:
# 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.pubMine looks like this:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPf//JKhgCR5CLNEhbi0TwbGuP6P8cpV2FxSdsvjXKFU tony@runbooks-demoGo 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:
$ ssh -T [email protected]
Hi tonhe! You've successfully authenticated, but GitHub does not provide shell access.If you see that “Hi
Now switch your existing repo’s remote from HTTPS to SSH:
$ 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:
---
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"
doneIf 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
onas a boolean, which means an unquotedon:key can be parsed asTrue: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.iosandcisco.asaare not part of core Ansible. If you skip that step,ansible-lintand--syntax-checkboth 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 pushGitHub 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/2A 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/77308809727Looking 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:
{
"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"]: theyamljob from our workflow has to pass before the branch can be merged. (The name matches thejobs.yamlblock 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: falseandallow_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 totrueif you want zero exceptions.
Send it:
$ gh api -X PUT repos/tonhe/runbooks-demo/branches/main/protection \
--input branch-protection.jsonThe 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:
pip install pre-commitMake a config file at the root of the repo, called .pre-commit-config.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: gitleaksWhat 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-fixerandtrailing-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-commitThat 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.................................................PassedNow 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: 1Commit 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/ @tonheThe 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:15ZCheck out somebody else’s PR locally (this is the killer feature for Ansible work):
gh pr checkout 7That 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:
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 26266024387watch 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):
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:
gh repo view --web
gh pr view --web 7These 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.
Get the rest of Git for Network Engineers in your inbox.