Dependency Pinning: Floating Versions Are a Supply-Chain Decision

Lockfiles, ranges, cooldowns and PR checks: a practical dependency pinning policy that holds without drowning engineers in upgrades.
Dependency pinning decides who can change your software. Why floating versions are a supply-chain risk and how to enforce pinning on the pull request.
May 4, 2026

Every dependency declaration is a decision about who gets to change your software. A pinned version says "this exact code, reviewed once." A floating range says "whatever the maintainer, or whoever holds the maintainer's credentials, publishes next." Most teams make that second decision thousands of times without noticing.

Dependency pinning is not a style preference. It decides whether a build you tested on Tuesday is the same build you ship on Thursday, and whether a hijacked package reaches you in hours or waits for a human to approve it. This post covers what pinning actually protects, where it goes wrong, and how to enforce it without drowning engineers in upgrade PRs.

What dependency pinning means, ecosystem by ecosystem

"Pinned" means different things depending on where you look. There are two layers in almost every ecosystem, and they are easy to confuse.

  • The manifest is what a developer declares: package.json, requirements.txt, pyproject.toml, Cargo.toml, pom.xml. It usually holds a range.
  • The lockfile is what the resolver actually chose: package-lock.json, poetry.lock, uv.lock, Cargo.lock, go.sum. It holds exact versions and, in most ecosystems, content hashes.

A range in the manifest is fine when a lockfile exists, is committed, and is honored by the build. It becomes a live risk when any of those three conditions fails.

npm, Yarn and pnpm

npm install lodash writes "lodash": "^4.17.21" by default. The caret allows any later 4.x release. That is safe only while package-lock.json is committed and CI installs with npm ci, which fails if the lockfile and manifest disagree instead of quietly re-resolving.

Python

Python is where floating versions hide most often. A requirements.txt line like requests>=2.20 or a bare requests resolves to whatever is newest at install time. There is no lockfile unless you add one, with pip-tools, Poetry, PDM or uv. Hash-checking mode (pip install --require-hashes) goes further and refuses any artifact whose content differs from what you recorded.

Go, Cargo and Maven

Go's module system is the exception that proves the rule. Minimum version selection plus go.sum means a build resolves the same versions until someone edits go.mod. Cargo behaves like npm: serde = "1.0" is a caret range, and Cargo.lock is what makes the build reproducible. Maven projects rarely use ranges, but version ranges and the LATEST and RELEASE meta-versions still exist and are discouraged for exactly this reason.

Why floating versions are a supply-chain risk

The argument for ranges is real: you pick up bug fixes and security patches without anyone doing work. The argument against is that you also pick up anything else, from anyone who can publish.

The public record is not subtle about this. event-stream gained a malicious dependency in 2018 after a new maintainer took over. ua-parser-js shipped credential-stealing versions in 2021 after an account takeover. The colors and faker sabotage in 2022 came from the legitimate maintainer. The self-replicating npm worm of 2025 spread by publishing new versions of packages whose maintainers it had compromised. In every case, projects that floated on a range installed the bad version automatically. Projects that pinned only met it when a human chose to upgrade.

There are three distinct failure modes worth separating:

  • Malicious publish. A compromised or hostile maintainer ships a new version. Ranges pull it in on the next fresh install.
  • Silent drift. A PR passes CI today and resolves a different transitive tree tomorrow. The code under review is not the code that ships.
  • Unreproducible incidents. When something breaks or a CVE lands, nobody can say which version was deployed last month, because it was whatever the resolver picked that day.

Pinning closes the first gap only partly. A pinned version can still be vulnerable, and a pinned version that was malicious on the day you pinned it is still malicious. What pinning buys you is control over when change arrives, so that review and scanning happen before it runs, not after.

The pinning trade-off, stated honestly

Teams that pin aggressively hit a predictable wall: the upgrade backlog. Every pinned dependency is now a decision someone has to make again, and there are a lot of them.

That cost is real, but it is a remediation problem, not a reason to float. The answer to "pinning creates too many upgrade PRs" is to make upgrades cheap and safe, not to hand version selection to the registry. A practical policy separates the two concerns:

  1. Pin what runs. Lock every resolved version, direct and transitive, with hashes where the ecosystem supports them.
  2. Upgrade on purpose. Move versions through a reviewed change, whether a human or an automated fix opens it.
  3. Wait before trusting something new. Most malicious versions are found and pulled within days. A cooldown, where a version must have been public for a set period before you adopt it, removes most of that exposure at almost no cost.

Cooldowns are going mainstream. Renovate has a minimumReleaseAge setting, and pnpm added a minimumReleaseAge option of its own. The idea is simple: a version that has survived a week of public scrutiny is a safer bet than one published an hour ago.

Where unpinned dependencies actually come from

Almost nobody decides to float. Unpinned requirements arrive through ordinary work:

  • A quick pip install in a Dockerfile, copied from a README.
  • A new service scaffolded from a template whose requirements.txt never had versions.
  • A * or latest added to get a failing build green.
  • A lockfile deleted to resolve a merge conflict, then regenerated against newer versions.
  • An AI coding agent adding a dependency with whatever version syntax it saw most in training data, often unpinned.

That last one matters more every quarter. Agents add dependencies quickly and confidently, and the diff looks routine. A reviewer checking the logic of a change will rarely stop to ask why a new requirement has no version.

How to enforce dependency pinning at the pull request

Pinning policy fails when it lives in a wiki. It holds when it is checked at the point the change is proposed, on the pull request, where the author can fix it in the same commit.

A useful PR check for pinning answers four questions:

  • Did this change add a dependency without a concrete version? Bare names, *, latest, x and open-ended ranges all count.
  • Is it direct or transitive? Some teams gate only what they declare. Others also gate what a new direct dependency drags in.
  • How new is the version? A release published yesterday deserves more suspicion than one that has been public for months.
  • Should this block or warn? Blocking works for production services. Warning is often right for internal tools and experiments.

Two rollout rules keep this from turning into a fight with engineering. Check only what the pull request introduces, so nobody inherits a failure for a range someone else wrote years ago. And start in warn or observe mode, watch what fires for a couple of weeks, then turn on blocking where the signal is clean.

A dependency pinning checklist

  • Commit lockfiles for every application. Libraries can leave them out, deployable services should not.
  • Install from the lockfile in CI: npm ci, poetry install --sync, uv sync --frozen, cargo build --locked.
  • Fail CI when the lockfile is missing or out of date instead of regenerating it on the fly.
  • Use hash verification where the ecosystem supports it.
  • Pin container base images by digest and GitHub Actions by full commit SHA. Tags are mutable in both.
  • Set a minimum release age for new versions in your update tooling.
  • Gate new unpinned requirements on the pull request, direct first, then transitive once the noise is understood.
  • Make upgrades a routine, reviewed change so the backlog never becomes the argument for floating.

How Heeler handles pinning

Heeler's PR Guardrails include an Unpinned Dependency rule that fires when a pull request adds a dependency without a concrete version pin, including bare wildcards such as *, latest and x, open-ended ranges and missing constraints. You scope it to direct dependencies, transitive, or all, and choose whether it warns or blocks. A Dependency Version Minimum Age rule sits alongside it and flags a version that is newer than the cooldown window you set. Both are covered in the dependency guardrails documentation.

The same policy applies to your CI pipelines. For GitHub Actions, a tag or branch reference such as @v3 or @main counts as unpinned, and guardrails can block unpinned actions or enforce a minimum age on them, as described in the GitHub Actions supply chain guide.

Where Python versions cannot be resolved to a concrete release, because of a marker or a wildcard that matches nothing published, Heeler shows them as unresolved rather than dropping them. You see that coverage is partial instead of assuming it is complete.

The other half is the upgrade backlog. SCA Auto-fix computes the upgrade from your resolved dependency graph, validates it through your CI and opens a merge-ready pull request, so moving a pinned version forward is a review, not a project. Pin everything, and let the fixes come to you.

FAQ

Should libraries pin their dependencies?

Libraries should declare compatible ranges and let the application that installs them lock exact versions. Pinning inside a library forces every consumer onto one version and causes resolution conflicts. Applications and services are where exact pins and committed lockfiles belong.

Is a lockfile enough, or do I need exact versions in the manifest too?

A committed lockfile that CI actually installs from gives you reproducible builds. The manifest range still matters when the lockfile is regenerated, which is why a PR check on new unpinned requirements is useful even in locked projects.

Does pinning stop supply-chain attacks?

Pinning stops a new malicious version from reaching you automatically. It does not protect you from a version that was already malicious when you pinned it, so pair pinning with malicious-package detection and a minimum release age.

What is a reasonable minimum release age?

A few days to a week covers the window in which most malicious npm and PyPI versions have historically been detected and removed. Make an explicit exception path for urgent security fixes so the cooldown never delays a patch you need.

How do I roll out a pinning policy without blocking every team?

Check only what each pull request introduces, start in warn mode, and move to blocking once the findings are ones engineers agree with. Enforcing on new changes stops the problem growing while the existing backlog is cleaned up separately.

Floating versions hand version selection to whoever controls the registry that day. If you want pinning enforced at the pull request, with the resulting upgrades opened as validated fixes instead of a growing backlog, Get a demo.

What’s new on Heeler
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Related resources

See All Resources