The moment a role has two consumers, it has an interface. The moment it has two consumers who need different versions of it, it needs to be versioned.
Those two moments are usually months apart, and doing the work at the first one is premature while doing it at the second is late. This lesson is about recognising the point in between.
When to extract
Section titled “When to extract”Not on the second use. A role used by two playbooks in one repository is fine where it is. Extracting it adds a version to manage and a repository to maintain, and buys nothing while both consumers are always on the same commit.
When two consumers need different versions. This is the actual signal. One team needs the new behaviour; another cannot take it yet. Inside one repository that is impossible; with versioning it is a requirements.yml entry.
When consumers are in different repositories. A relative path does not cross a repository boundary, so the choice is copying — which diverges — or versioning.
When somebody outside your team consumes it. Then it is a published artifact whether you intended that or not.
The cost of extracting early: a repository with three commits, one consumer, and a release process nobody exercises. When the second consumer arrives, the versioning discipline has already lapsed.
Roles or collections
Section titled “Roles or collections”Both are units of distribution and they are not equivalent.
A role is one unit of automation — tasks, handlers, templates, defaults. Distributed standalone from a Git repository or Galaxy.
A collection is a namespace containing roles, modules, plugins and playbooks, versioned as a whole. example.platform containing eight roles and two custom modules.
| Standalone role | Collection | |
|---|---|---|
| Contains | One role | Roles, modules, plugins, playbooks |
| Versioning | Per role | The whole collection |
| Custom modules | No | Yes |
| Namespacing | Role name only | namespace.collection.role |
| Current direction | Legacy-ish | The modern unit |
Collections are where the ecosystem has gone. Modules and plugins ship in them, fully-qualified names reference them, and Galaxy is organised around them.
Standalone roles still work and are appropriate for a single role with no modules that you want to distribute simply.
The practical guidance: one shared role, a standalone role repository is fine. Several related roles, or anything with a custom module, a collection.
Git sources
Section titled “Git sources”Both are consumable directly from Git, which is how private code is shared without publishing anywhere.
roles: - name: app_deploy src: https://github.com/example-org/ansible-role-app-deploy.git scm: git version: v2.4.1
collections: - name: community.general version: "<pinned>" # An exact release, not a range - name: example.platform source: https://github.com/example-org/ansible-collection-platform.git type: git version: v1.4.0version takes a tag, a branch or a commit. Use a tag.
A branch reference is the failure this lesson exists to prevent. version: main means a colleague merging into the role repository changes what your playbook does, with no commit in your repository and nothing to review. The next ansible-galaxy install --force picks it up, and the change appears at whatever moment somebody happens to run that.
A commit SHA is the only genuinely immutable reference. Tags are immutable by convention; a force-pushed tag rewrites what every consumer resolves. Protect tags in the role repository if you publish for others.
SSH sources work for private repositories, and the CI runner needs a deploy key with read access — Git credentials covers the options.
Semantic versioning for roles
Section titled “Semantic versioning for roles”The useful definition of “breaking” is what happens to a consumer who upgrades without changing their configuration.
Major — their playbook stops working or does something different. A required variable added with no default. A variable renamed or removed. A task’s behaviour changed such that a previously idempotent run now changes something. A supported platform dropped.
Minor — new capability, no action required. An optional variable with a default. A new task guarded by a variable that defaults to off. Support for another distribution.
Patch — a fix with no interface change.
The category people get wrong is a changed default. Altering app_deploy_port from 8080 to 8000 requires no configuration change from a consumer and changes what happens on their hosts. That is major, and it looks like a one-character patch.
Removing a supported platform is major. A role that dropped support for an older distribution breaks consumers still running it, and the failure appears at run time on a host rather than at install time.
Deprecate before removing. A variable marked deprecated in release notes for one major version, then removed in the next, gives consumers a window. Ansible has no deprecation mechanism for role variables, so this is documentation plus, ideally, a task that fails with a clear message when the old variable is set.
The release workflow
Section titled “The release workflow”-
Change on a branch, one concern.
-
Update the README’s variable table. It is the interface documentation and it is the thing consumers read.
-
Open a pull request. A role change affects every consumer, which makes it higher-stakes than a change in a consuming repository.
-
CI runs lint and Molecule. For a shared role this is not optional — consumers cannot easily verify it themselves.
-
Decide the version. Apply the definition above. When uncertain between minor and major, choose major.
-
Merge, then tag the merge commit.
git tag -a v2.4.0 -m "Add optional log rotation". -
Write release notes for a consumer. What changed, what they must do, and any deprecation. Not the internal diff.
-
For a collection, bump the version in
galaxy.ymlbefore tagging — the file and the tag must agree, and a mismatch produces an artifact whose version is not what the tag says.
Step 7 is the one that determines whether consumers upgrade. A tag with no notes forces every consumer to read a diff to decide whether it matters, and most will not.
Building and publishing a collection
Section titled “Building and publishing a collection”ansible-galaxy collection buildansible-galaxy collection publish example-platform-1.4.0.tar.gzgalaxy.yml carries the namespace, name, version, dependencies and supported Ansible versions.
Publishing to Galaxy is for public collections. Private ones are consumed from Git, or from a private automation hub if your organisation runs one.
Build in CI, from a tag, for the same reasons as container releases: the artifact comes from reviewed code, and nobody publishes from a laptop with uncommitted changes.
Do not re-publish a version. A consumer who installed 1.4.0 yesterday and installs it again today must get the same thing. If a release is wrong, publish 1.4.1.
Consuming and upgrading
Section titled “Consuming and upgrading”An upgrade is a one-line pull request.
- name: example.platform source: https://github.com/example-org/ansible-collection-platform.git type: git version: v1.3.2 version: v1.4.0The diff is one line and the effect can be large. Read the release notes, not the diff — which is the same discipline as Terraform module upgrades and for the same reason.
Test in development first. Upgrade the development inventory’s run, observe, then production. The whole reason for having a development environment.
One upgrade per pull request. Bundling three makes an unattributable failure.
Run the playbook in check mode after upgrading, where the modules support it honestly. It catches some changes before they reach a host.
Watch for idempotency regressions. A role upgrade that makes a previously idempotent run report changes is a role that will now restart services on every run. Molecule’s idempotence step catches this in the role’s own CI; a consumer notices it as unexplained handler firing.
Keeping upgrades from rotting
Section titled “Keeping upgrades from rotting”Pinning has one cost: nothing forces you to move.
Automate the pull request, not the merge. A scheduled job noticing a newer tag and opening a pull request keeps the option visible.
Track who consumes what. Across repositories this needs an inventory. “Which playbooks are on the old version of the deploy role” should take seconds.
Upgrade on a cadence. A team upgrading quarterly does small upgrades. One upgrading when forced does a large migration under pressure, across several major versions, with no way to attribute a failure.
Prune consumers you forgot about. A repository still pinned to v1.0.0 from two years ago is either abandoned or a liability, and finding out which is worth an hour.
Testing a shared role
Section titled “Testing a shared role”Higher bar than an internal one, because consumers cannot easily verify it.
Lint on every pull request.
Molecule with a scenario per supported platform. A role claiming to support three distributions and testing one is a role that supports one.
Test the defaults. A role run with no variables set should work or fail with a clear message — not fail obscurely three tasks in.
Test idempotency, which Molecule does as a step.
Test the documented variables actually work. A README listing a variable the role no longer reads is a common and unhelpful failure.
Version-matrix testing against the Ansible versions you claim to support, if that range is wide. meta/main.yml declares a minimum; testing it is what makes the claim true.
Designing a role interface
Section titled “Designing a role interface”Versioning is only worth doing if the thing being versioned has a stable interface, and most upgrade pain traces to interface decisions made when the role had one consumer.
Expose intent, not implementation. A role taking nginx_worker_processes, nginx_worker_connections and nginx_keepalive_timeout has exposed nginx’s configuration file. One taking web_expected_concurrency has exposed a decision, and can change how it achieves it.
Every required variable is a future breaking change. If a value has a reasonable default, give it one. A role with eight required variables is a role every consumer configures identically and painfully.
Do not accept raw passthrough. A variable that takes a block of arbitrary configuration and splices it into a template makes every consumer’s usage unique and the role impossible to change.
Keep the role’s scope to one thing. A role installing a package, configuring it and managing its data cannot be versioned meaningfully — a change to any part forces a version bump for consumers using only the others.
Name variables after what they control, prefixed with the role name. app_deploy_listen_port is unambiguous; port collides.
Document the platforms you support in meta/main.yml and test them. A consumer discovering at run time that the role assumes systemd is a poor experience.
The test for a good interface: can you change how the role works internally without changing its variables? If every implementation change is also an interface change, it is a template rather than an abstraction, and it will produce a major version every month.
Migrating a role out of a repository
Section titled “Migrating a role out of a repository”The extraction, done so nothing breaks.
-
Confirm it is worth it. Two consumers needing different versions, or a consumer in another repository. If neither applies, stop.
-
Create the new repository with the role’s directory as its root —
tasks/,defaults/,templates/at the top level, not nested underroles/name/. -
Preserve history if it matters.
git subtree splitor a filter tool can extract the role’s commits. Worth it for a role with meaningful history; not worth it for one written last month. -
Add lint and Molecule before anything consumes it. A shared role without tests is a shared role nobody can change safely.
-
Tag
v1.0.0. Even if the code is unchanged — the version is a statement about the interface from now on. -
Point one consumer at it through
requirements.yml, and remove the local copy. -
Run against a disposable target and compare to what the local copy produced. Identical output is the verification.
-
Move the other consumers, one at a time.
-
Delete the original, once nothing references it.
Step 7 is the migration. An identical result proves the extraction was a no-op; anything else means something changed in the move and you want to know what before it reaches a host.
Do not change the role while extracting it. Two sources of difference in one diff, and the verification stops meaning anything.
Common mistakes
Section titled “Common mistakes”version: main in requirements.yml. Consumers change when somebody merges elsewhere.
Extracting with one consumer. Version overhead, no benefit.
A changed default treated as a patch. No configuration change required, different behaviour on hosts.
Not using --force or a clean directory in CI. A stale role runs while the repository says otherwise.
Committing downloaded roles and collections. Nobody can tell what is yours.
No release notes. Consumers cannot decide, so they do not upgrade.
Version in galaxy.yml not matching the tag. An artifact whose version is a lie.
Re-publishing a version. The same name means two things.
Claiming platform support without testing it. Discovered by a consumer at run time.
Never upgrading. One enormous migration later.
Third-party roles and collections
Section titled “Third-party roles and collections”Most estates consume somebody else’s automation, and that is a supply-chain decision with a sharper edge than usual.
A role runs tasks you did not write, on your hosts, usually as root. That is a broader trust grant than a library in an application, and it deserves reading before adoption rather than after.
Read what it does. Not the README — the tasks. A widely used role can install packages, add repositories, change firewall rules and create users from a five-line invocation.
Watch for added package repositories. A role adding a third-party repository to your hosts has extended your supply chain to whoever runs it, permanently, for every package installed thereafter.
Pin the version, always. A floating dependency means an upgrade happens when the maintainer publishes rather than when you decide.
Prefer collections from the project maintaining the software, or from the well-known community collections, over an individual’s role that has not been touched in three years.
Check the maintenance signals — recent commits, open issues answered, whether the platforms you use are actually tested.
Consider vendoring anything critical. Copying a role into your own repository costs upstream fixes and buys control over when anything changes, plus the ability to review it in your own process. For a role that configures something central, that trade is often correct.
Prefer wrapping to forking. A thin role of your own that calls the third-party one with your defaults gives you a place to add your conventions without taking on maintenance of the whole thing.
Ownership
Section titled “Ownership”A shared role with no owner decays in a predictable way: consumers add the variable they need, nobody removes anything, and after two years the interface has thirty variables of which nine are used by one caller each. At that point no change is safe.
Put role repositories under CODEOWNERS. The team accountable reviews changes, which matters more here than for a consuming repository because a bad change reaches every host every consumer manages.
Distinguish contributing from owning. Consumers should open pull requests — that is how a role improves. The owning team decides what merges and what version it becomes.
State what the role is for in the README, including what it deliberately does not do. That sentence lets an owner decline a feature request without it seeming arbitrary, and it stops a focused role becoming a general-purpose one.
Watch for the platform team becoming a queue. A team owning every role for every other team is a bottleneck. The healthier arrangement is owning the genuinely shared ones and letting teams keep their own until something is used twice.
Mental model
Section titled “Mental model”A shared role is a published interface — its variables — with an implementation behind it. Versioning is how a consumer chooses when to accept a change to that interface, and a floating reference is a choice they never got to make.
Everything follows: tags so consumers can choose, semantic versioning so the choice is informed, release notes so it is cheap, and tests so the interface is what the README says.
What you learned
Section titled “What you learned”- Extract when two consumers need different versions, not on the second use
- Collections are the modern unit; standalone roles remain fine for a single shared role
- Git sources with a tagged
versionare how private code is shared version: mainmeans production changes when somebody merges in another repositoryansible-galaxy installdoes not re-fetch by default — use--forceor a clean directory in CI- A changed default is a major version, however small the diff
- Release notes determine whether anybody upgrades
- Test defaults, idempotency and every platform you claim to support
Exercise
Section titled “Exercise”Use two disposable repositories and a container. No production hosts.
-
Create a role repository with a role that creates a file whose path comes from a variable with a default. Tag
v1.0.0. -
Create a consumer repository with a
requirements.ymlpinningv1.0.0. Install and run against a container. -
Change the role’s default path and push to
mainwithout tagging. Re-run the consumer’s install and playbook. Predict: does anything change? -
Change the reference to
version: main, install with--force, and run. Predict: what changes, and how would a reviewer have known? -
Install without
--forceafter changing the version. Predict: which version actually runs? -
Tag the changed default as
v1.0.1— a patch. Consider what a consumer upgrading would experience. Predict: was patch the right classification? -
Delete both repositories and the container.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.