AnsibleXXVII · Collections, Galaxy and Dependency TrustCollections and dependency trust
requirements.yml and version pinning
What you'll learn
- Write a requirements.yml for collections and roles with exact version pins
- Use the version specifier syntax ansible-galaxy actually accepts, and recognise the syntax it rejects
- Explain why an unpinned dependency makes today and tomorrow different changes
- Find the transitive dependencies a pin does not cover, and pin those too
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Version pinning gets filed under tidiness, alongside consistent indentation and sorted lists. It is not tidiness. It is the difference between an automation repository that describes a change and one that describes an intention to run whatever is current.
Here is the whole argument in one sentence. Your repository has not changed since Tuesday. Your run on Thursday does something different. That is what an unpinned dependency buys you, and no amount of review discipline on your own code prevents it, because the change was not in your code.
The file
---
collections:
- name: community.docker
version: '4.7.0'
- name: ansible.posix
version: '3.0.0'
- name: community.general
version: '12.4.0'
roles:
- name: geerlingguy.postgresql
version: '3.5.2'Two top-level keys, both optional. collections: and roles: are
resolved by different machinery — roles predate collections and use a
different index — so a file with both is normal and each half is
installed independently.
$ ansible-galaxy collection install -r requirements.yml -p ./collectionsStarting galaxy collection install process
Process install dependency map
Starting collection install process
Downloading https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-docker-4.7.0.tar.gz to /home/opsuser/.ansible/tmp/ansible-local-1601101soz9l_f/tmpx0j7iwi0/community-docker-4.7.0-zbsncrpu
Installing 'community.docker:4.7.0' to '/home/opsuser/project/collections/ansible_collections/community/docker'
community.docker:4.7.0 was installed successfully
Downloading https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-library_inventory_filtering_v1-1.1.5.tar.gz to /home/opsuser/.ansible/tmp/ansible-local-1601101soz9l_f/tmpx0j7iwi0/community-library_inventory_filtering_v1-1.1.5-rnua6vrl
Installing 'community.library_inventory_filtering_v1:1.1.5' to '/home/opsuser/project/collections/ansible_collections/community/library_inventory_filtering_v1'
community.library_inventory_filtering_v1:1.1.5 was installed successfullyRead that output again. You asked for one collection and two were installed. The second is a transitive dependency, and it is the subject of the second half of this lesson.
The specifier syntax, verified
The version key accepts exact versions and comma-separated range
expressions. An exact pin:
collections:
- name: community.docker
version: '4.7.0'A range resolves at install time to whatever is newest inside it:
$ ansible-galaxy collection install -r req.yml -p ./gx --no-depsDownloading https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-crypto-2.26.9.tar.gz to /home/opsuser/.ansible/tmp/ansible-local-182176gyjuqyyk/tmpf9q18xw4/community-crypto-2.26.9-mk4xhvbf
Installing 'community.crypto:2.26.9' to '/home/opsuser/project/gx/ansible_collections/community/crypto'
community.crypto:2.26.9 was installed successfullyThe file said '>=2.0.0,<3.0.0'. What landed was 2.26.9 — because
that is the newest 2.x today. Run the same file next quarter and you
get 2.27.something, from the same unchanged commit. The range is a
policy; the pin is a fact.
Not every syntax you have met elsewhere is accepted. The npm-style caret is not:
$ ansible-galaxy collection install -r req-caret.yml -p ./gx3[ERROR]: Non integer values in LooseVersion ('^2.0.0')
Starting galaxy collection install process
Process install dependency mapUse >=, >, <=, <, !=, ==, *, and commas to combine them.
'*' means “any”, which is also what you get when you omit version
entirely — and it is the state this lesson exists to argue against.
The flags that matter
| Flag | What it does | When you want it |
|---|---|---|
-r requirements.yml | Install everything in the file | Always, in a repository |
-p ./collections | Install to this path instead of the first configured one | Always, on a shared controller |
-U, --upgrade | Upgrade installed collections, and their dependencies unless --no-deps | Deliberately, as its own reviewed change |
-n, --no-deps | Do not install declared dependencies | Building an offline bundle, or when you pin the dependencies yourself |
--pre | Consider pre-release versions, which are ignored by default | Testing an upcoming release, never in production |
-f, --force | Overwrite an existing collection | Repairing a partial install |
--offline | Install from local tarballs without contacting any server | Air-gapped install; lesson 7 |
The pin that does not hold
Back to the two collections from one request. Where did the second one come from? From inside the first one’s manifest:
$ python3 -c "import json; m=json.load(open('collections/ansible_collections/community/docker/MANIFEST.json')); print(m['collection_info']['dependencies'])"{'community.library_inventory_filtering_v1': '>=1.0.0'}>=1.0.0. Unbounded. You pinned community.docker to exactly 4.7.0,
and the collection you pinned brings in a second collection with a
range that will resolve to whatever is newest whenever the install runs.
Your requirements.yml is pinned. Your controller is not.
Finding what you actually installed
The fix is mechanical. Install once, list what landed, and pin all of it:
$ ansible-galaxy collection list -p ./collections --format json{"/home/opsuser/project/collections/ansible_collections": {"community.docker": {"version": "4.7.0"}, "community.library_inventory_filtering_v1": {"version": "1.1.5"}}}Then write every line of that into requirements.yml with an exact
version, and install with --no-deps so the resolver takes your list
rather than re-deriving one:
---
# Direct dependencies.
collections:
- name: community.docker
version: '4.7.0'
# Transitive: pulled in by community.docker, which declares it as
# '>=1.0.0'. Pinned here so a rebuild reproduces this controller
# rather than today's newest.
- name: community.library_inventory_filtering_v1
version: '1.1.5'ansible-galaxy collection install -r requirements.yml -p ./collections --no-depsThe comment is not decoration. In eighteen months somebody will find a collection in that file that no playbook imports and delete it, and the comment is the only thing that stops them.
Knowledge check
Knowledge check · 4 questions
Q1. requirements.yml pins community.docker to exactly 4.7.0. A controller rebuilt from that file six months later behaves differently. What is the most likely cause?
Q2. Which statements about ansible-galaxy collection install are accurate? Select all that apply.
Q3. version: '>=2.0.0,<3.0.0' in requirements.yml produces a reproducible install.
Q4. What is the reviewable way to move a collection from 4.7.0 to 4.8.0 on a production controller?
Passing score: 75%. Answers are checked in this browser.