Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCIII · Infrastructure Repository Anti-PatternsAntiPatterns

Mutable dependencies and loose tags — the tag that is not a contract

Intermediate⏱ ~26 mingit

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

Not yet marked complete on this device.

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 a ref = constraint.
  • Terraform providers. A required_providers block that lists a version range rather than a pinned version, and no .terraform.lock.hcl checked in.
  • Container image references. Kubernetes manifests and CI workflow steps that reference image: nginx:latest or image: myorg/api:v1 without a digest.
  • GitHub Actions and similar. uses: actions/checkout@v4, where the @v4 is 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.hcl file 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

  1. 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.
  2. 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.
  3. Updates go through pull requests. A new version of a pinned dependency is a change, and changes are reviewed.
  4. 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.hcl discipline for state-bearing modules.

Quiz

Knowledge check · 4 questions

  1. 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?

  2. 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.

  3. Q3. Name the four surfaces in an IaC repository where loose tags appear, and identify the pinning primitive that applies to each.

  4. 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.