Your Ansible playbooks depend on third-party Galaxy roles and collections. How do you make those dependencies reproducible in CI?
answer
- no lock file, so pin by hand
- declare it or it is not a dependency
- project-local path, not the machine
- a passing install can be a no-op
- bumps are reviewed commits
basics
~20 sDeclare every dependency in a version-controlled requirements.yml with an exact version, install it in CI with ansible-galaxy role install -r and ansible-galaxy collection install -r into a project-local path, and never rely on whatever happens to be on the control node.
solid answer
~50 sTreat external roles and collections the way you treat any other dependency: pinned, declared in the repository, installed fresh. Put every one in `requirements.yml` with an exact `version`, not a floating branch, and commit it. In CI install into a project-local path (`-p ./roles`, `-p ./collections`) or set `roles_path`/the collections path in `ansible.cfg`, so the job cannot silently pick up a globally installed copy. Do not vendor the downloaded content into git unless you have an offline requirement - if you do, vendor it deliberately and regenerate it as a reviewed commit. The gotcha to name is that `ansible-galaxy role install` will not replace a role that is already present on disk even when `requirements.yml` asks for a newer version; it skips it silently unless you pass `--force`. On a reused CI runner or a cached workspace, that means an old role version surviving a bump. Either install into a clean directory each run or force the reinstall.
code
bash · 9 lines#!/usr/bin/env bash
set -euo pipefail
rm -rf ./roles ./collections
ansible-galaxy role install -r requirements.yml -p ./roles --force
ansible-galaxy collection install -r requirements.yml -p ./collections --force
ansible-galaxy collection list
ansible-playbook -i inventory/prod site.yml --checkgo deeper
Know that external roles and collections belong in requirements.yml with a version, and that CI installs them with ansible-galaxy rather than someone doing it by hand on a server.
Explain exact pinning versus branches, installing into a project-local path with -p or roles_path, and why a globally installed role makes a build unreproducible.
Name the install-skips-existing trap on reused runners and the fixes, and take a position on vendoring versus installing, with the offline case as the deciding factor.
Own the dependency policy for the estate: which upstreams are permitted, private hub versus public Galaxy, who reviews a version bump, and how the engine version is pinned and rolled forward across all repositories.
## The problem Ansible has no lock file. Nothing records the exact versions a successful run used, so if your dependency declaration is loose - a branch name, a missing `version`, or nothing at all because someone installed the role by hand on the control node - two runs of the same commit can execute different third-party code. That is the failure to design against. ## Declare everything in requirements.yml ```yaml # requirements.yml roles: - src: geerlingguy.nginx version: "3.1.4" - src: https://github.com/acme/ansible-role-internal.git scm: git version: v2.3.0 name: acme_internal collections: - name: community.general version: "8.6.1" - name: https://github.com/acme/collection.git type: git version: v1.4.2 ``` Rules that matter: - **Pin exact versions.** `version: main` or an omitted `version` means "whatever the upstream tip is today". - Prefer tags over branches for git sources; a tag is a stable pointer, a branch is not. - Give git-sourced roles an explicit `name`, otherwise the installed directory is derived from the repository URL and is easy to get wrong in `import_role`. ## Install into a project-local path ```bash ansible-galaxy role install -r requirements.yml -p ./roles ansible-galaxy collection install -r requirements.yml -p ./collections ``` Or configure it once so every invocation agrees: ```ini # ansible.cfg [defaults] roles_path = ./roles collections_path = ./collections ``` Project-local installation is what stops a job succeeding because of a role that a human installed on the runner last month and that nothing declares. It also lets two projects on the same machine use different versions of the same role. ## The install-skips-existing trap `ansible-galaxy role install` checks whether the role directory already exists and, if it does, leaves it alone rather than reinstalling. On a fresh container that never matters. On a **reused CI runner or a cached workspace**, it matters a great deal: you bump `geerlingguy.nginx` from 3.1.4 to 3.2.0 in `requirements.yml`, CI reports a successful install, and 3.1.4 keeps running. Two robust responses: - Install into a directory the job creates fresh each run (a clean container, or delete the path first). - Pass `--force` so the install always replaces what is there. If you cache the install directory to save time, key the cache on a hash of `requirements.yml` so a change to the file invalidates it. ## Vendoring, and when it is right Vendoring means committing the downloaded roles and collections into your repository. It gives you a true lock - the exact bytes are in git - and it works with no network at apply time, which is the deciding factor in air-gapped or tightly-egressed environments. The costs are a noisy repository, large diffs, and the temptation for someone to edit vendored code in place, which silently forks it from upstream. If you vendor, make regeneration a scripted, reviewed commit and forbid local edits; if you do not, make sure the install step is deterministic instead. ## Supply chain and provenance A Galaxy role is arbitrary code that will run as root on your fleet, so treat the dependency list as a security surface, not just a build detail. Pinning is itself a control - it stops an upstream push changing what runs without a review. Beyond that, prefer sources you can attribute, review the diff when you bump a version rather than rubber-stamping it, and for internal content publish to a private hub. `ansible.cfg` supports a `[galaxy]` `server_list` naming the servers to use, with per-server sections carrying the URL and token, so a CI job can be pointed at an internal hub instead of public Galaxy. ## Do not forget ansible-core itself The engine is a dependency too. Pin `ansible-core` (or the `ansible` bundle) in the CI image or a Python requirements file, because collection content declares a minimum core version and behaviour changes between minors. "Reproducible dependencies, floating engine" is only half the job. ## The interview answer in one shape Declared, pinned, project-local, installed fresh, bumped by reviewed commit - and know why a passing install step can still leave the old version on disk.
- You bumped a role version in requirements.yml, CI reported a successful install, and the old behaviour persisted. What happened?`ansible-galaxy role install` leaves an existing role directory alone rather than replacing it, so on a reused runner or cached workspace the old version stayed and the step still exited zero. Fix it by installing into a directory the job wipes first, or by passing `--force`; if you cache the path, key the cache on a hash of `requirements.yml`.
- Would you vendor third-party roles into the repository instead?Only for a real constraint - an air-gapped or egress-restricted environment, or a hard requirement that the exact bytes are in git. It buys a true lock at the cost of repository noise and the risk of someone editing vendored code in place. If you vendor, regenerate it by script as a reviewed commit and forbid local edits.
- How do you keep a third-party role from becoming a supply-chain problem?Pin exact versions so an upstream push cannot change what runs without review, read the diff when you bump rather than rubber-stamping it, and prefer attributable sources. For internal content, publish to a private hub and point CI at it via the `[galaxy]` `server_list` in `ansible.cfg`. Remember the role runs as root on the fleet.
- What else besides the roles and collections needs pinning?`ansible-core` itself, plus the Python dependencies of the collections you use. Collections declare a minimum core version and behaviour shifts between minors, so a floating engine reintroduces exactly the variability you removed. Pin the version in the CI image or a Python requirements file and bump it deliberately.
saying these in an interview costs you the question
- Relies on roles a human installed on the control node
- Uses version: main or omits version entirely
- Assumes ansible-galaxy install always upgrades
- Says Ansible has a lock file like Terraform
- Pins the roles but lets ansible-core float