> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# A Git Pipeline for Network Configs: Lint, Check-Mode Diff, Then Deploy
- URL: https://www.pinglabz.com/ansible-git-pipeline-ci/
- Published: 2026-11-01T04:47:17.000Z
- Updated: 2026-11-01T04:55:06.000Z
- Description: A plain Git server with a pre-receive hook as the CI gate: yamllint, ansible-lint and a check-mode run against six IOS XE devices before a push to main is accepted, then a deploy step. Two real rejections, one real change, and what --diff did not show.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

Up to now in this series, every change has started with me running `ansible-playbook` from a terminal. That works for one person. Once two people edit the same playbooks, you want what software teams have: changes go into Git, something checks them before they land, and the deploy runs from what was merged, not from somebody's laptop.

This post builds that with nothing but Git and the tools already on the control node. A bare Git repository acts as the server. Its `pre-receive` hook is the CI gate: yamllint, ansible-lint and a check-mode run against the lab, and if any of them fail, the push to `main` is rejected. Its `post-receive` hook is the deploy step.

I built it in my home lab against the same six IOS XE devices. Every run below is real, including the two pushes the gate rejected. The GitHub Actions file near the end is the one thing I did not run, and it's labelled.

Versions: ansible-core 2.21, cisco.ios 11.6, yamllint 1.38.0, ansible-lint 26.10.1, git 2.53, IOS XE 17.18.2.

[![Lab diagram: an engineer merges a feature branch to main on a laptop and runs git push origin main to a bare Git repository on the control node. Hook 1, pre-receive, is the CI gate: it archives the pushed commit, runs yamllint, runs ansible-lint with the production profile, and does a check-mode run against the devices that prints any pending config lines without pushing config. If any step fails the push is rejected, shown as an orange return arrow with a fault mark: push 2 was rejected by yamllint for trailing spaces, and push 3 by ansible-lint for name[missing]. If the gate passes, the branch is updated and hook 2, post-receive, deploys the same commit with ansible-playbook over SSH to six devices: hq-core1, hq-core2, hq-edge1, br1-edge1, br2-edge1 and the access switch br1-acc1. A write memory handler runs only on devices that changed. Push 1 was the baseline with all six ok and nothing changed; on push 4 only hq-core2 changed, the other five were ok, and Save config ran on hq-core2.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-13.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-13.png?ref=pinglabz.com)

Push to main, gate it, then deploy. The check run printed pending config lines only from push 5 on, after that task was added.

## What's in the repo

The change being managed is deliberately small: SNMP location and contact, one value per device in `host_vars`. Small enough that the pipeline is the interesting part, real enough to show a diff.

```text
.ansible-lint
.gitignore
.yamllint
ansible.cfg
ci/check.sh
ci/deploy.sh
inventory/group_vars/all/connection.yml
inventory/group_vars/all/vault.yml
inventory/group_vars/sites.yml
inventory/host_vars/br1-acc1.yml
...
inventory/host_vars/hq-edge1.yml
inventory/hosts.yml
playbooks/snmp-location.yml

```

The device credentials are in `vault.yml`, encrypted with Ansible Vault as in [the lab setup post](https://www.pinglabz.com/ansible-network-lab-setup-first-playbook-ios-xe/). The vault password file stays on the control node and is never committed, so the repo is safe to clone but only the runner can deploy. The connection file looks like this, with placeholders:

```yaml
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_user: netauto
ansible_password: "{{ vault_ios_password }}"
ansible_become: true
ansible_become_method: enable
ansible_become_password: "{{ vault_enable_password }}"

```

The data:

```yaml
# inventory/group_vars/sites.yml
---
snmp_contact: noc@example.net

# inventory/host_vars/hq-core2.yml
---
snmp_location: HQ data room, rack A3

```

The playbook:

```yaml
# playbooks/snmp-location.yml
---
- name: SNMP location and contact from Git
  hosts: sites
  gather_facts: false
  tasks:
    - name: Set location and contact
      cisco.ios.ios_config:
        lines:
          - "snmp-server location {{ snmp_location }}"
          - "snmp-server contact {{ snmp_contact }}"
      register: snmp
      notify: Save config

    - name: Show the lines that would be sent
      ansible.builtin.debug:
        var: snmp.updates
      when: snmp.updates | default([]) | length > 0

  handlers:
    - name: Save config
      cisco.ios.ios_command:
        commands:
          - write memory

```

The second task wasn't in the first version, and pushes 1 to 4 ran without it. I'll get to why it's there.

## The checks

Both linters run from config files in the repo, so everyone gets the same rules:

```yaml
# .yamllint
---
extends: default
rules:
  line-length: {max: 140}
  truthy: {allowed-values: ["true", "false"]}
  comments: {min-spaces-from-content: 1}
  document-start: disable

```

```yaml
# .ansible-lint
---
profile: production
exclude_paths:
  - .git/
  - inventory/group_vars/all/

```

The `production` profile is the strictest ansible-lint profile. The vault directory is excluded because ansible-lint can't read encrypted files without the password.

The CI script and the deploy script:

```bash
# ci/check.sh
#!/usr/bin/env bash
# CI gate: lint, then a check-mode diff against the lab. Non-zero exit blocks the merge.
set -euo pipefail
cd "${1:-.}"
echo "== yamllint"
yamllint -s .
echo "== ansible-lint"
ansible-lint -q playbooks/
echo "== check-mode diff"
ansible-playbook playbooks/snmp-location.yml --check --diff

```

```bash
# ci/deploy.sh
#!/usr/bin/env bash
# Deploy: apply what was checked, from the exact commit that was pushed.
set -euo pipefail
cd "${1:-.}"
echo "== deploy $(git -C . rev-parse --short HEAD 2>/dev/null || cat .commit)"
ansible-playbook playbooks/snmp-location.yml

```

## The Git server hooks

The server is a bare repository on the control node, created with `git init --bare -b main`. Anyone with SSH access to the box can push to it. These two hooks live in its `hooks/` directory:

```bash
# hooks/pre-receive
#!/usr/bin/env bash
# CI gate on the server: every push to main is linted and check-mode diffed before it is accepted.
set -uo pipefail
. ~/ansible-venv/bin/activate
export ANSIBLE_DEPRECATION_WARNINGS=False ANSIBLE_FORCE_COLOR=0
while read -r old new ref; do
  [ "$ref" = "refs/heads/main" ] || continue
  work=$(mktemp -d)
  git archive "$new" | tar -x -C "$work"
  echo "CI: checking $(git rev-parse --short "$new") for $ref"
  if ! "$work/ci/check.sh" "$work"; then
    echo "CI: FAILED, push to main rejected"; rm -rf "$work"; exit 1
  fi
  rm -rf "$work"
  echo "CI: passed"
done

```

```bash
# hooks/post-receive
#!/usr/bin/env bash
# Deploy step: runs only after pre-receive accepted the push.
set -uo pipefail
. ~/ansible-venv/bin/activate
export ANSIBLE_DEPRECATION_WARNINGS=False ANSIBLE_FORCE_COLOR=0
while read -r old new ref; do
  [ "$ref" = "refs/heads/main" ] || continue
  rm -rf ~/netops-deploy && mkdir ~/netops-deploy
  git archive "$new" | tar -x -C ~/netops-deploy
  git rev-parse --short "$new" > ~/netops-deploy/.commit
  ~/netops-deploy/ci/deploy.sh ~/netops-deploy 2>&1 | tee ~/netops-deploy.log
done

```

`pre-receive` runs before Git updates any branch, and a non-zero exit rejects the whole push. It extracts the pushed commit with `git archive` into a temp directory, so it checks exactly what would land, not whatever happens to be in a working copy. Only `main` is gated; feature branches can be pushed freely.

`post-receive` runs after the update is accepted, so it can't block anything. It extracts the same commit into a deploy directory and runs the playbook for real.

## Push 1: the baseline

The repo values match what's already on the devices, so the first push should check and deploy with nothing to change:

```text
remote: CI: checking 0032400 for refs/heads/main
remote: == yamllint
remote: == ansible-lint
remote: == check-mode diff
remote:
remote: PLAY [SNMP location and contact from Git] **************************************
remote:
remote: TASK [Set location and contact] ************************************************
remote: ok: [hq-edge1]
remote: ok: [br1-edge1]
remote: ok: [hq-core2]
remote: ok: [br1-acc1]
remote: ok: [hq-core1]
remote: ok: [br2-edge1]
remote:
remote: PLAY RECAP *********************************************************************
remote: br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote: hq-core2                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote:
remote: CI: passed
remote: == deploy 0032400
remote:
remote: PLAY [SNMP location and contact from Git] **************************************
remote:
remote: TASK [Set location and contact] ************************************************
remote: ok: [hq-core1]
remote: ok: [hq-edge1]
remote: ok: [br1-acc1]
remote: ok: [br1-edge1]
remote: ok: [hq-core2]
remote: ok: [br2-edge1]
remote:
remote: PLAY RECAP *********************************************************************
remote: br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote: hq-core2                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote:
To ~/git/netops.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

real    0m30.613s
user    0m27.481s
sys 0m5.348s

```

About 30 seconds for the whole push, most of it the two playbook runs.

## Push 2: rejected by yamllint

hq-core2 moved racks. I changed it on a branch, merged to `main` and pushed. The edit had a trailing space after the value:

```text
$ git push origin main
remote: CI: checking ba46fee for refs/heads/main
remote: == yamllint
remote: ./inventory/host_vars/hq-core2.yml
remote:   2:37      error    trailing spaces  (trailing-spaces)
remote:
remote: CI: FAILED, push to main rejected
To ~/git/netops.git
 ! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '~/git/netops.git'
exit=1
$ git log --oneline origin/main
0032400 Baseline: SNMP location and contact for six devices

```

Nothing was deployed and `origin/main` didn't move. A trailing space wouldn't have broken anything here, but the same check catches tab indentation and duplicate keys, which do.

## Push 3: rejected by ansible-lint

I fixed the space and, in the same commit, added a quick verification play with an unnamed task:

```text
$ git push origin main
remote: CI: checking b5b7e42 for refs/heads/main
remote: == yamllint
remote: == ansible-lint
remote: name[missing]: All tasks should be named.
remote: playbooks/snmp-location.yml:24 Task/Handler: cisco.ios.ios_command commands=show snmp location
remote:
remote: CI: FAILED, push to main rejected
To ~/git/netops.git
 ! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '~/git/netops.git'
exit=1

```

yamllint passed this time. ansible-lint's production profile requires every task to have a name, because the name is all you see in the run log when something fails at 2 AM.

## Push 4: accepted and deployed

I dropped the unnamed play and pushed again:

```text
$ git push origin main
remote: CI: checking 8ff55cc for refs/heads/main
remote: == yamllint
remote: == ansible-lint
remote: == check-mode diff
remote:
remote: PLAY [SNMP location and contact from Git] **************************************
remote:
remote: TASK [Set location and contact] ************************************************
remote: [WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
remote: ok: [br1-acc1]
remote: ok: [br1-edge1]
remote: changed: [hq-core2]
remote: ok: [hq-core1]
remote: ok: [hq-edge1]
remote: ok: [br2-edge1]
remote:
remote: RUNNING HANDLER [Save config] **************************************************
remote: [WARNING]: Only show commands are supported when using check mode, not executing write memory
remote: ok: [hq-core2]
remote:
remote: PLAY RECAP *********************************************************************
remote: br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote: hq-core2                   : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote:
remote: CI: passed
remote: == deploy 8ff55cc
remote:
remote: PLAY [SNMP location and contact from Git] **************************************
remote:
remote: TASK [Set location and contact] ************************************************
remote: [WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
remote: ok: [hq-edge1]
remote: ok: [br1-acc1]
remote: changed: [hq-core2]
remote: ok: [br1-edge1]
remote: ok: [hq-core1]
remote: ok: [br2-edge1]
remote:
remote: RUNNING HANDLER [Save config] **************************************************
remote: ok: [hq-core2]
remote:
remote: PLAY RECAP *********************************************************************
remote: br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote: hq-core2                   : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote:
To ~/git/netops.git
   0032400..8ff55cc  main -> main
exit=0
$ git log --oneline --graph origin/main
* 8ff55cc Drop unnamed quick-check play
* b5b7e42 Fix trailing space, add quick check
*   ba46fee Merge move-hq-core2
|\
| * bd39e18 hq-core2 moved to rack A4
|/
* 0032400 Baseline: SNMP location and contact for six devices

```

The check run flagged hq-core2 as the only device that would change, and the deploy then changed exactly that device. In the deploy run, the handler saved the config. In check mode the handler doesn't run `write memory`, even though the handler still reports ok for hq-core2\. The warning says so: "Only show commands are supported when using check mode, not executing write memory".

The `ios_config` warning about lines being "similar to how they appear" in the running config shows up whenever this task reports a change. It's a general reminder, not an error; the lines here match the running config format.

## What --diff didn't show

That run had a problem I didn't notice until I read it again: `--check --diff` printed no diff. `ios_config` reports `changed` in check mode, but it only prints a diff when you set `diff_against`, and that compares whole configs, which is noisy. What it does return is `updates`, the exact lines it would send.

So I added the second task to the playbook, pushed it through the gate (all six `ok`, task skipped), then moved hq-core2 back to rack A3 to see the result:

```text
$ git show --stat HEAD | tail -2
 inventory/host_vars/hq-core2.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push origin main
remote: CI: checking 8c11ea1 for refs/heads/main
remote: == yamllint
remote: == ansible-lint
remote: == check-mode diff
remote:
remote: PLAY [SNMP location and contact from Git] **************************************
remote:
remote: TASK [Set location and contact] ************************************************
remote: [WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
remote: ok: [br1-edge1]
remote: ok: [hq-edge1]
remote: ok: [br1-acc1]
remote: ok: [hq-core1]
remote: changed: [hq-core2]
remote: ok: [br2-edge1]
remote:
remote: TASK [Show the lines that would be sent] ***************************************
remote: skipping: [hq-core1]
remote: skipping: [hq-edge1]
remote: skipping: [br1-edge1]
remote: skipping: [br1-acc1]
remote: skipping: [br2-edge1]
remote: ok: [hq-core2] => {
remote:     "snmp.updates": [
remote:         "snmp-server location HQ data room, rack A3"
remote:     ]
remote: }
remote:
remote: RUNNING HANDLER [Save config] **************************************************
remote: [WARNING]: Only show commands are supported when using check mode, not executing write memory
remote: ok: [hq-core2]
remote:
remote: PLAY RECAP *********************************************************************
remote: br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
remote: ...
remote: hq-core2                   : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote:
remote: CI: passed
remote: == deploy 8c11ea1
remote:
remote: PLAY [SNMP location and contact from Git] **************************************
remote:
remote: TASK [Set location and contact] ************************************************
remote: [WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
remote: ok: [hq-core1]
remote: ok: [br1-acc1]
remote: changed: [hq-core2]
remote: ok: [br1-edge1]
remote: ok: [hq-edge1]
remote: ok: [br2-edge1]
remote:
remote: TASK [Show the lines that would be sent] ***************************************
remote: skipping: [hq-core1]
remote: skipping: [hq-edge1]
remote: skipping: [br1-acc1]
remote: skipping: [br1-edge1]
remote: skipping: [br2-edge1]
remote: ok: [hq-core2] => {
remote:     "snmp.updates": [
remote:         "snmp-server location HQ data room, rack A3"
remote:     ]
remote: }
remote:
remote: RUNNING HANDLER [Save config] **************************************************
remote: ok: [hq-core2]
remote:
remote: PLAY RECAP *********************************************************************
remote: br1-acc1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
remote: ...
remote: hq-core2                   : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
remote: ...
remote:
To ~/git/netops.git
   07e8af9..8c11ea1  main -> main
exit=0
$ show run | i snmp-server location   (hq-core2, after deploy)
        "snmp-server location HQ data room, rack A3"
$ show startup-config | i snmp-server location   (hq-core2)
        "snmp-server location HQ data room, rack A3"
$ git log --oneline origin/main
8c11ea1 hq-core2 back to rack A3
07e8af9 CI: print pending config lines
8ff55cc Drop unnamed quick-check play
b5b7e42 Fix trailing space, add quick check
ba46fee Merge move-hq-core2
bd39e18 hq-core2 moved to rack A4
0032400 Baseline: SNMP location and contact for six devices

```

Now a reviewer reading the CI log sees the actual command that will be sent before it is sent. That's the part of a "check-mode diff" that matters on network devices.

Checked on the device afterwards:

```text
$ show run | i snmp-server location   (hq-core2, after deploy)
        "snmp-server location HQ data room, rack A3"
$ show startup-config | i snmp-server location   (hq-core2)
        "snmp-server location HQ data room, rack A3"

```

The lab is back where it started.

## One thing the gate doesn't do

Look at the history after the last push:

```text
8c11ea1 hq-core2 back to rack A3
07e8af9 CI: print pending config lines
8ff55cc Drop unnamed quick-check play
b5b7e42 Fix trailing space, add quick check
ba46fee Merge move-hq-core2
bd39e18 hq-core2 moved to rack A4
0032400 Baseline: SNMP location and contact for six devices

```

The two commits that failed are in `main`. The hook checks the commit at the tip of the push, and when push 4 went through, the earlier commits came with it. That's fine for deployment, because only the tip is ever deployed. If you need every commit to pass, loop over `git rev-list $old..$new` in the hook, or squash on merge.

## The same pipeline on GitHub Actions (not run)

My lab devices aren't reachable from GitHub's hosted runners, so I didn't run this. It's an untested example of the same gate as a workflow on pull requests, which would need a self-hosted runner that can reach the devices and the vault password stored as a repository secret:

```yaml
# .github/workflows/network-ci.yml  (example only, not run in this post)
name: network-ci
on:
  pull_request:
    branches: [main]
jobs:
  check:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - name: Write vault password
        run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ~/.pl-ansible-vault-pass
      - name: Lint and check-mode run
        run: ci/check.sh .

```

The deploy would be a second job on `push` to `main` running `ci/deploy.sh`. The scripts are the same; that's the reason to keep the logic in `ci/` rather than in the hook or the workflow file.

## Cleanup

Nothing is scheduled. The hooks only run when someone pushes, there's no cron entry or timer, and no Ansible process was left running after the last push. The repo and the bare server repository stay on the control node for the next post.

[Part 25](https://www.pinglabz.com/ansible-semaphore-ui-network-playbooks/) covers running the playbooks from Semaphore UI: templates, a vault key, a schedule and the failed-run view. The rest of the series is on the [series page](https://www.pinglabz.com/ansible-network-automation/).