Git, CI/CD & GitOpsCIII · Infrastructure Repository Anti-PatternsAntiPatterns
Mutable dependencies and loose tags — the tag that is not a contract
What you'll learn
- Identify the four dependency surfaces in an IaC repository where loose tags are common
- Explain why a tag is a mutable pointer and a digest is an immutable content address
- Distinguish version-range constraints from pinned references and choose the right one for production
- Apply pinning disciplines across Terraform modules, providers, container images, and GitHub Actions
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A latest tag is a contract that resolves to whatever
someone pushed most recently. A v1.0 tag is a contract
that the maintainer can move. A Git commit SHA is a contract
that the bytes themselves produce. The first two are
mutable; the third is content-addressed.
Four surfaces where loose tags appear
The anti-pattern appears at four surfaces in an IaC repository, each with a different pinning mechanism:
- Terraform module sources.
source = "git::https://.../repo.git"without aref =constraint. - Terraform providers. A
required_providersblock that lists a version range rather than a pinned version, and no.terraform.lock.hclchecked in. - Container image references. Kubernetes manifests and
CI workflow steps that reference
image: nginx:latestorimage: myorg/api:v1without a digest. - GitHub Actions and similar.
uses: actions/checkout@v4, where the@v4is a tag the maintainer can move.
flowchart LR
A["module source"] --> D["loose reference"]
B["provider version"] --> D
C["image tag"] --> D
E["action tag"] --> D
D --> F["mutable contract"]
D --> G["maintainer can rewrite"]
F --> H["non-deterministic build"]
G --> I["supply-chain attack surface"]
All four are the same anti-pattern: a reference that resolves to whatever someone else publishes at the moment of the build.
Why a tag is not an immutable reference
A Git tag is a pointer. The name is human-meaningful; the object the tag points to is whatever the maintainer decided to point at the moment the tag was last moved. A maintainer who deletes a tag and recreates it pointing at a different commit has rewritten the contract. The name is the same; the bytes are not.
git ls-remote https://github.com/example/repo.git v1.0
git ls-remote https://github.com/example/repo.git $COMMIT_SHA
The first may return a different SHA tomorrow; the second cannot, because the SHA is a property of the bytes.
What pinning looks like at each surface
The four surfaces have four different pinning mechanisms, but the principle is the same: reference the bytes, not the label:
- Terraform modules. Pin by Git ref with a commit SHA
(
ref = "v1.0.0"plus a SHA verification step). - Terraform providers. A
.terraform.lock.hclfile checked in with each provider pinned to its platform hash. - Container images.
image: myorg/api:v1.0.0@sha256:abc...in Kubernetes manifests. - GitHub Actions. The full 40-character commit SHA, not the tag.
In each case the pinned reference is a content address.
Why version ranges are not pinning
A version = "~> 1.0" constraint is a range that Terraform
interprets at terraform init time. The range may resolve to
1.0.0 today and 1.0.7 tomorrow if the registry publishes a
new patch release. A .terraform.lock.hcl file is the
pinning discipline: it records the exact provider version
and platform hash the last successful init selected.
The same logic applies to Helm chart versions, Ansible collection versions, and npm-style semver ranges.
Production discipline
- Pin by SHA or digest, not by tag. Module sources, provider versions, image references, and GitHub Action references all carry a content address; use it.
- Lock files are tracked.
.terraform.lock.hcl,package-lock.json,go.sum,Cargo.lock- the lock file is the record of what was pinned and when. - Updates go through pull requests. A new version of a pinned dependency is a change, and changes are reviewed.
- Tags are for humans, digests are for machines. A
release may be tagged
v1.2.0; consumers pull the digest the tag points at.
Cross-course references
- This course, Part LXVII (Pinning) covers the pinning discipline across actions, images, providers, modules, collections, and packages.
- This course, Part LXIX (ArtifactSigning) covers the signature layer on top of content addressing.
- This course, Part LXVI (SupplyChainAttacks) covers the mutable-reference attack that pinning prevents.
- Terraform for Production Sysadmins Parts IX-XII cover
the
.terraform.lock.hcldiscipline for state-bearing modules.
Quiz
Knowledge check · 4 questions
Q1. A Terraform module is referenced as `source = git::https://github.com/example/repo.git` with no ref constraint. Why is this a supply-chain risk?
Q2. A Kubernetes image reference of `myorg/api:v1.0.0@sha256:abc123...` is more secure than `myorg/api:v1.0.0` because the digest pins the bytes.
Q3. Name the four surfaces in an IaC repository where loose tags appear, and identify the pinning primitive that applies to each.
Q4. Diagnose a supply-chain incident caused by a mutable reference, and recommend the pinning discipline that prevents it.
A Kubernetes manifest deploys `image: thirdparty/api:v1` without a digest. The third-party maintainer's account is compromised; the attacker republishes `v1` pointing at a malicious image. Within 24 hours, the cluster pulls the malicious image on the next reconciliation cycle. The malicious code exfiltrates IAM credentials from the pod's environment.
Passing score: 75%. Answers are checked in this browser.