Introduction
Automated testing is essential for maintaining quality in Ansible collections. GitHub Actions provides native CI/CD that runs lint checks, sanity tests, unit tests, and integration tests on every pull request — catching issues before they reach main. This article covers the complete workflow setup using the ansible-network/github_actions reusable workflows, custom test configurations, and best practices for collection CI/CD.
Quick Start: Minimal Workflow
Create .github/workflows/test.yml in your collection repository:
name: CI
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
branches: [main]
workflow_dispatch:
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6 AM
jobs:
ansible-lint:
uses: ansible-network/github_actions/.github/workflows/ansible-lint.yml@main
changelog:
uses: ansible-network/github_actions/.github/workflows/changelog.yml@main
sanity:
uses: ansible-network/github_actions/.github/workflows/sanity.yml@main
unit-galaxy:
uses: ansible-network/github_actions/.github/workflows/unit_galaxy.yml@main
integration:
uses: ansible-network/github_actions/.github/workflows/integration_simple.yml@main
all_green:
if: ${{ always() && (github.event_name != 'schedule') }}
needs:
- changelog
- sanity
- unit-galaxy
- integration
runs-on: ubuntu-latest
steps:
- run: >-
python -c "assert set([
'${{ needs.changelog.result }}',
'${{ needs.sanity.result }}',
'${{ needs.unit-galaxy.result }}',
'${{ needs.integration.result }}'
]) == {'success'}"
Understanding Each Job
ansible-lint
Runs ansible-lint against the collection to catch:
- YAML syntax issues
- Deprecated module usage
- Missing FQCN (Fully Qualified Collection Names)
- Task naming violations
- Jinja2 best practices
changelog
Validates changelog fragments exist for PRs. Ansible collections use changelogs/fragments/ with YAML files:
# changelogs/fragments/fix-timeout-handling.yml
bugfixes:
- Fix timeout handling in my_module (https://github.com/org/repo/pull/42).
sanity
Runs ansible-test sanity which checks:
- Python import validation
- Documentation formatting
- GPL license headers
- PEP 8 compliance
- YAML syntax
- Proper module documentation
- Return value documentation
unit-galaxy
Runs unit tests from tests/unit/ using ansible-test units:
# tests/unit/plugins/modules/test_my_module.py
from unittest.mock import patch
from ansible_collections.my_ns.my_col.plugins.modules import my_module
class TestMyModule:
def test_create_resource(self):
set_module_args({"name": "test", "state": "present"})
with patch.object(my_module, "create_resource") as mock_create:
mock_create.return_value = {"id": "123"}
result = my_module.main()
assert result["changed"] is True
integration
Runs integration tests from tests/integration/targets/:
tests/integration/targets/
├── my_module/
│ ├── tasks/
│ │ └── main.yml
│ └── defaults/
│ └── main.yml
# tests/integration/targets/my_module/tasks/main.yml
---
- name: Create resource
my_ns.my_col.my_module:
name: test_resource
state: present
register: result
- name: Verify creation
ansible.builtin.assert:
that:
- result.changed
- result.resource.name == 'test_resource'
all_green
A gate job that ensures all other jobs passed before the PR can be merged. Uses Python assertion to check all results equal 'success'.
Workflow Configuration Deep Dive
Concurrency Control
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
This ensures:
- Only one workflow runs per PR branch at a time
- New pushes cancel the previous run (saves runner minutes)
- Manual/scheduled runs each get their own group
Trigger Events
on:
pull_request:
branches: [main] # Test every PR to main
workflow_dispatch: # Manual trigger from GitHub UI
schedule:
- cron: '0 6 * * 1' # Weekly: catch upstream breakage
push:
branches: [main] # Optional: test after merge
Path Filtering (Save Runner Time)
on:
pull_request:
branches: [main]
paths:
- 'plugins/**'
- 'tests/**'
- 'meta/**'
- '.github/workflows/**'
paths-ignore:
- '**.md'
- 'docs/**'
- 'changelogs/**'
Custom Workflows
Run Against Multiple Ansible Versions
jobs:
sanity:
strategy:
matrix:
ansible-version:
- stable-2.16
- stable-2.17
- devel
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 ansible-core
run: pip install https://github.com/ansible/ansible/archive/${{ matrix.ansible-version }}.tar.gz
- name: Run sanity tests
run: ansible-test sanity --color -v --docker
Run ansible-lint Locally First
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ansible-lint
uses: ansible/ansible-lint@main
with:
args: ""
Integration Tests with Docker Services
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
pip install ansible-core psycopg2-binary
ansible-galaxy collection install community.postgresql
- name: Run integration tests
run: ansible-test integration --color -v my_module
env:
POSTGRESQL_HOST: localhost
POSTGRESQL_PASSWORD: test
Collection Directory Structure
my_namespace/my_collection/
├── .github/
│ └── workflows/
│ └── test.yml
├── changelogs/
│ ├── changelog.yaml
│ └── fragments/
├── docs/
├── galaxy.yml
├── meta/
│ └── runtime.yml
├── plugins/
│ ├── modules/
│ ├── module_utils/
│ ├── inventory/
│ └── filter/
├── roles/
├── tests/
│ ├── integration/
│ │ └── targets/
│ ├── unit/
│ │ └── plugins/
│ └── sanity/
└── README.md
Required Files for CI
galaxy.yml
namespace: my_namespace
name: my_collection
version: 1.0.0
readme: README.md
authors:
- Your Name <email@example.com>
description: My Ansible collection
license_file: LICENSE
tags:
- networking
- cloud
dependencies:
ansible.netcommon: ">=5.0.0"
meta/runtime.yml
requires_ansible: ">=2.15.0"
plugin_routing:
modules:
old_module:
redirect: my_namespace.my_collection.new_module
.ansible-lint
profile: production
skip_list:
- yaml[line-length]
Branch Protection Rules
Configure GitHub branch protection to require CI:
- Settings → Branches → Add Rule
- Branch pattern:
main - Enable Require status checks to pass
- Select required checks:
all_green - Enable Require branches to be up to date
Troubleshooting
Sanity Test Failures
# Run locally to debug
ansible-test sanity --color -v --docker
ansible-test sanity --color -v --test pep8
ansible-test sanity --color -v --test import
Integration Test Timeouts
# Increase timeout for slow tests
integration:
uses: ansible-network/github_actions/.github/workflows/integration_simple.yml@main
with:
timeout-minutes: 60
Changelog Missing
# Create a fragment before submitting PR
cat > changelogs/fragments/my-fix.yml << 'EOF'
bugfixes:
- Fix connection timeout in my_module.
EOF
Best Practices
- Run tests locally first —
ansible-test sanity --dockerbefore pushing - Use matrix testing — test against multiple ansible-core versions
- Pin reusable workflow versions — use
@v1.0.0tags not@main - Add path filters — skip CI for docs-only changes
- Keep integration tests fast — use mocks where possible
- Require
all_green— enforce via branch protection rules - Add changelog fragments — every PR should include one
- Schedule weekly runs — catch upstream ansible-core breakage early
Related Articles
Conclusion
GitHub Actions provides everything you need for Ansible collection CI/CD. Use the ansible-network/github_actions reusable workflows for quick setup, or build custom workflows with matrix testing across multiple ansible-core versions. The all_green gate pattern ensures no PR merges without passing lint, sanity, unit, and integration tests. Combine with branch protection rules for a reliable, automated quality gate.