BuildsTOOL-SPECIFICGENERAL

Dependency Pinning

Pinning buys reproducibility and tamper-evidence, and hands you the update duty the range was performing on your behalf.

The question, the obvious approach, and why it breaks

Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.

The production question

Should dependencies be pinned to exact versions, and what do you take on when you do?

The problem

A version range means the set of code you ship changes without any change on your side. A pin means it does not — and neither does the security patch you needed.

What teams do first

Use ranges so security patches arrive automatically. Pinning everything is how projects end up years out of date.

How it breaks

A range delivers whatever is published, not what you reviewed. The build on Tuesday and the build on Wednesday can ship different code from the same commit (Reproducible Builds).

How it breaks in production
  • A range delivers whatever is published, not what you reviewed. The build on Tuesday and the build on Wednesday can ship different code from the same commit (Reproducible Builds).
  • "Patches arrive automatically" only holds if you rebuild. Many services build on merge, so a patch published today reaches production whenever someone next changes something — an arrival schedule nobody chose.
  • Ranges make rollback ambiguous. Reverting your commit does not revert the resolved dependency set, so "the previous version" may not be reconstructible.
  • They are also the injection path: a compromised package published inside your accepted range reaches your build with no human decision at any point (Typosquatting and Malicious Packages).
  • But the criticism of pinning is fair. A pinned dependency receives nothing — no security patch, no bug fix — unless someone moves the pin, and pins with no update process are how a project ends up four years behind (Vulnerability Management by Exposure).
CodeBuildTestArtifactReleaseDeployRunObserveOperateIncidentRecoverLearnImprove

What is actually happening

Underneath the tooling, which is the part that survives a change of tool.

  • A pin removes a decision from build time and moves it to a commit. That is the entire mechanism, and both the benefit and the cost follow from it.
  • Pins have strengths. A version number says "this label"; a content hash says "these bytes". Only the second is tamper-evident, because a version number depends on the registry continuing to serve the same content under that label.
  • Pinning is only meaningful over the transitive closure. Pinning direct dependencies while their dependencies float leaves most of the shipped code unpinned — which is why the lockfile, not the manifest, is the pinning mechanism.
  • The same logic applies to every input, not just packages: base images by digest, CI actions by commit SHA, toolchains by version, runner images where the platform allows it.
  • The update duty is not optional overhead — it is the work the range was doing invisibly, made visible and given a schedule. Pinning without automation trades one silent risk for another.
  • Pinning plus automated update proposals is the combination that gets both properties: nothing changes without a commit, and commits proposing changes arrive continuously (Dependency Management).

What each kind of pin actually pins

These are all called pinning and they offer different guarantees. The two columns to read together are the last two: what the pin protects against, and who is now responsible for updating it.

FormExampleDeterministic?Tamper-evident?Who updates it
Range^4.17.0No — resolves at build timeNoThe publisher, silently
Mutable tagFROM node:20-alpineNo — the tag can be movedNoThe image publisher, silently
Exact version4.17.21Yes, if the registry serves the same bytesNo — the label is not the contentYou, by editing the manifest
Lockfile with integrity hashespackage-lock.jsonYes, over the transitive closureYes — mismatched content fails the installYou, by committing a lockfile change
Content digestnode@sha256:9c4b...Yes, by constructionYes — the name is the contentYou, by editing the digest
Vendored sourcecommitted vendor/Yes, and visible in reviewYes, via version control historyYou, and it is the most work

Pinning the direct set is not pinning

The most common half-measure: exact versions in the manifest, nothing about what those versions bring with them. It looks pinned in review and is not pinned in the artefact.

Two repositories that both say they pin dependencies
Manifest pins only
package.json
  "express":  "4.19.2"     exact
  "pg":       "8.11.5"     exact
  "zod":      "3.23.8"     exact

no lockfile committed

  pinned:    3 packages
  floating:  ~340 transitive packages
  same commit, next month: different artefact
Locked closure
package.json      (ranges are fine here)
package-lock.json (committed, reviewed)
  every resolved package, exact version
  + integrity sha512-... per package

CI: npm ci      (installs from lock, fails on mismatch)
not: npm install (may re-resolve and rewrite the lock)

  pinned:    all ~343
  same commit, next month: same artefact

The transitive closure is what ships. Pinning the three names you typed while three hundred others float means the artefact still changes without a commit — you have only pinned the part that was already visible in review. Note the install command: using the resolving command in CI silently discards the lockfile guarantee you thought you had.

The duty you just accepted

ORG-SPECIFICThe response window, the auto-merge policy and who owns the update queue are team decisions, not facts. What is not optional is that someone owns it: pinning transfers a duty from the ecosystem to your team, and an unowned duty is not performed (The Ownership Record).

Every row here is a real failure of pinning-without-process. None of them is an argument against pinning; each is an argument for the automation that has to come with it.

TriggerSymptomCauseResponse
Advisory published for a pinned dependencyScanner flags the artefact; nothing arrives automaticallyPins do not update themselves — that is the point of themAn update bot plus a defined response window; prioritise by exposure, not by CVSS alone (Scanning, and Why a Finding Is Not a Risk)
Nobody merged bot pull requests for six monthsUpdate pull request now touches hundreds of packagesThe backlog was never boundedBound it: a weekly slot, or auto-merge patches with a trusted suite. Then upgrade in small batches (Change Size: Why Small Changes Are Safer, and When They Are Not)
Base image pinned by tagSame Dockerfile, different image contents on different daysA tag is a mutable pointer, not a pinPin by digest, with the human-readable version in a comment (Layers and the Build Cache)
Base image pinned by digest, never updatedOS packages inside the image are years oldThe pin froze the OS layer too, which was the intent and is also the problemAutomate base image bumps on the same cadence as packages (What Image Size Actually Costs)
Pinned version yanked from the registryHistorical commits no longer buildYour build depends on someone else's retention policyMirror what you depend on; it is the only durable answer (Securing the Pipeline Itself)
Auto-merge enabled on a flaky suiteA regression merges; nobody read the changeThe merge decision was delegated to a signal that is only sometimes rightFix the flake rate before trusting auto-merge with a production decision (Flaky Tests)

How to do it properly

Most important first.

  • Pin the transitive closure with a committed lockfile containing integrity hashes, and install from the lockfile in CI.
  • Pin by digest wherever digests exist: container base images with FROM image@sha256:..., CI actions by commit SHA, published artefacts by digest (Tags Versus Digests).
  • Pin the toolchain and the runner image alongside the packages; an unpinned compiler makes a pinned dependency set only half a guarantee (Build Environments).
  • Pair every pin with automated update proposals from day one. Adopting pinning without the bot is choosing the worse half of the trade.
  • Review lockfile changes. A diff that moves the transitive closure is a change to what you ship.
  • Keep a mirror so a pinned version stays fetchable after it is yanked upstream (Securing the Pipeline Itself).
  • Watch what the pin does not cover — a pinned base image still contains OS packages that were current when the image was built, and those age (Scanning, and Why a Finding Is Not a Risk).

How much can this affect

Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.

Blast radius if this is wrongEveryone
One testEveryone
What contains it

An unpinned input can change what every future build contains, with no commit anywhere. A lockfile contains it by making every change explicit and reviewable.

What can go wrong

Failure modes, including of the mitigation
  • Pinning adopted, automation never added: the dependency set freezes and quietly accumulates known vulnerabilities.
  • Direct dependencies pinned, transitive ones floating, producing the illusion of a pinned build.
  • A tag treated as a pin. FROM node:20-alpine is a moving pointer; the same Dockerfile builds different images on different days (Mutable Servers and Immutable Images).
  • Bot pull requests auto-merged without a trustworthy suite, which is a range with extra ceremony.
  • A pinned version yanked upstream, so historical commits become unbuildable without a mirror.
  • Pinned packages inside an unpinned base image, so the OS layer drifts while the application layer does not.
  • Update pull requests batched into one enormous change because the backlog was ignored for six months, at which point every regression has hundreds of suspects.
Misreads this invites
  • "Pinning means never updating." Pinning means updating deliberately. Without an update process it does become never, which is a process failure rather than a property of pinning.
  • "An exact version is a pin." It is a label pin. Only a content hash pins the bytes — the distinction matters exactly when a registry serves something different under the same name (Artifact and Build Integrity).
  • "Ranges give us security patches." They give you whatever is published in the range, patch or regression or compromise, unreviewed.
  • "We pinned the base image to node:20.11-alpine." That is still a tag and it can move. node@sha256:... is the pin.

Operating it

Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.

How you know it worked
  • Building the same commit at two different times produces the same resolved dependency set — checked, not assumed (Reproducible Builds).
  • Every action, base image and toolchain reference in the repository resolves to a digest or an exact version; a grep confirms it.
  • Update pull requests exist, are recent, and are being merged — the pin is maintained rather than frozen.
  • The lag between an advisory being published and the fix being merged is measured.
  • A build with the public registry unreachable still succeeds, because pinned versions are mirrored.
How you get back
  • Pinning is reversible: loosen the constraint and the resolver takes over again. The reason not to is that you lose the ability to reconstruct what was shipped.
  • A bad update reverts by reverting the lockfile commit, and the previous version is guaranteed fetchable if it is mirrored — which is the argument for the mirror.
  • Rolling back a digest-pinned base image is exact by construction; rolling back a tag-pinned one is not, because you cannot name what the tag used to point at.
What to automate, and what stays human
  • Automate proposals — this is the load-bearing automation. Pinning without it is a decision to fall behind.
  • Automate the check that nothing is unpinned: a CI job that fails on a floating action reference, a tag-based FROM, or a range in a manifest that should be locked (Policy as Code).
  • Automate advisory matching against the pinned closure so the update queue is prioritised by exposure rather than by publication date.
  • Keep major-version adoption human. A major bump is an API migration and needs someone to read the changelog (Deprecation as a Process, Not a Label).
What this costs
  • Reproducibility and tamper-evidence in exchange for owning the update schedule. That is the trade, stated plainly, and it is not free in either direction.
  • Digest pins are the strongest and the least readable: nobody can tell from sha256:9c4b... which version it is, so the version belongs in a comment beside it.
  • A mirror gives you availability and history and is a service someone has to run.
  • Strict pinning of everything, including development-only tools, adds review load for changes that cannot affect production; scope the strictness to what ships.

Where this applies

This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.

  • TOOL-SPECIFICGo records module hashes in go.sum by default and verifies them, so Go projects are pinned whether or not anyone decided to be; npm and Cargo lockfiles carry integrity hashes but only apply when the install command reads the lockfile; Maven has no lockfile and requires dependency management sections or a plugin. The amount of work "pin your dependencies" describes varies by an order of magnitude.
  • GENERALThe trade — determinism and tamper-evidence in exchange for owning updates — is identical for packages, base images, CI actions and toolchains, and identical across every ecosystem.

Where the depth lives

This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.