Pinning Git dependencies to commit SHAs Jump to heading

Every major package manager can install a dependency directly from a Git repository: npm and pnpm, pip, Go modules, Cargo, Bundler, Composer. The convenient form names a branch or a tag — #main, @v2.1.0 — and that convenience is the problem. A branch moves with every push. A tag can be deleted and recreated pointing anywhere. Either way, two installs of the same manifest can produce different code, and the one that runs in production is whichever happened most recently. Pinning to a full commit hash makes the manifest name exactly one tree, permanently. This page covers pinning in the common ecosystems, checking pins in CI, and updating them without losing the guarantee, within submodule and dependency integrity.

When to use this approach Jump to heading

  • Any manifest in your repositories references a dependency by Git URL rather than a registry version.
  • You use forks of upstream libraries, installed from your fork’s repository.
  • You depend on internal libraries installed from private repositories rather than a private registry.
  • The same principle applies to CI actions; that case is covered in pinning GitHub Actions to a commit SHA.

Step 1 — Find every Git-sourced dependency Jump to heading

Start with an inventory. Each ecosystem spells Git dependencies differently, so search for the patterns rather than trusting memory.

# npm / pnpm / yarn
grep -nE '"(git\+|github:|git@|https://[^"]+\.git)' package.json
# pip
grep -nE 'git\+https?://|git\+ssh://' requirements*.txt pyproject.toml 2>/dev/null
# Cargo
grep -nE 'git *= *"' Cargo.toml
# Go: replace directives pointing at forks
grep -n 'replace' go.mod

For each hit, note whether it names a branch, a tag, a short hash or a full hash. Only the last is a pin.

What each kind of Git reference guaranteesA branch can move with every push and a tag can be deleted and recreated, so neither guarantees the same code twice. A short hash can become ambiguous as the repository grows. A full commit hash names exactly one tree for as long as the commit exists.Can change silently?Failure modebranch #mainevery pushdifferent code each installtag @v2.1.0if re-taggedrare, but invisibleshort hashnoambiguous laterfull 40-char hashnocommit deleted → install failsa pinned install either gets the exact code or fails loudly — never something in between

Step 2 — Rewrite references as full hashes Jump to heading

Resolve each branch or tag to its current commit and write the full hash into the manifest. Use git ls-remote so you do not need a clone.

# Resolve a tag (peeled to the commit) or a branch to its full hash
git ls-remote https://github.com/example/fast-parser.git 'refs/tags/v2.1.0^{}' 'refs/heads/main'
# package.json
"fast-parser": "github:example/fast-parser#5f3c9a1e7b2d4c6a8e0f1b3d5c7e9a1b2c4d6e8f"

# requirements.txt
fast-parser @ git+https://github.com/example/fast-parser.git@5f3c9a1e7b2d4c6a8e0f1b3d5c7e9a1b2c4d6e8f

# Cargo.toml
fast-parser = { git = "https://github.com/example/fast-parser", rev = "5f3c9a1e7b2d4c6a8e0f1b3d5c7e9a1b2c4d6e8f" }

Keep the human-readable version next to the hash in a comment where the format allows it, so reviewers can see what the hash is supposed to be.

# Verification: the lockfile resolves to the same hash
grep -n 5f3c9a1e package-lock.json Cargo.lock 2>/dev/null

Step 3 — Fail CI on any unpinned Git reference Jump to heading

A lint step stops new unpinned references from merging. Match Git dependency specifiers and require a forty-character (or sixty-four for SHA-256 repositories) hex reference.

#!/bin/sh
# ci/check-git-pins.sh — fails on Git dependencies not pinned to a full hash
set -eu
bad=$(grep -nhE 'git\+|github:|\.git[#@"]|git *= *"' package.json requirements*.txt Cargo.toml 2>/dev/null |
      grep -vE '[#@=" ]([0-9a-f]{40}|[0-9a-f]{64})\b' || true)
[ -z "$bad" ] || { echo "Unpinned Git dependencies:"; echo "$bad"; exit 1; }
Keeping Git dependencies pinnedAn inventory finds every Git-sourced dependency. Each is rewritten to a full hash. A CI lint rejects any new unpinned reference. An update bot or scheduled job proposes new hashes as pull requests with the upstream diff attached.Inventorygrep each manifestPinfull commit hashLint in CIreject #main, @v1UpdatePR with upstream diffpinning is a one-off; the lint and the update loop are what keep it true

Step 4 — Update pins deliberately, with the diff in front of a reviewer Jump to heading

Pinned dependencies do not update themselves, which is the point. When you do update, make the upstream changes visible in the pull request so the reviewer is approving code, not a hash.

old=5f3c9a1e7b2d4c6a8e0f1b3d5c7e9a1b2c4d6e8f
new=$(git ls-remote https://github.com/example/fast-parser.git 'refs/tags/v2.2.0^{}' | cut -f1)
tmp=$(mktemp -d); git -C "$tmp" init -q
git -C "$tmp" fetch -q https://github.com/example/fast-parser.git "$old" "$new"
git -C "$tmp" log --oneline "$old..$new"
git -C "$tmp" diff --stat "$old" "$new"

Paste the log and stat into the pull request description. Update bots such as Renovate can do this automatically for Git dependencies when configured to track tags and pin digests; see configuring Renovate for a monorepo.

Step 5 — Protect against the pinned commit disappearing Jump to heading

A pin fails closed: if the upstream deletes the commit, installs break rather than silently changing. That is better than the alternative, but still an outage. For dependencies you rely on, keep a mirror you control and point the pin at it.

# Install from your mirror at the same hash
"fast-parser": "git+https://git.example.com/mirrors/fast-parser.git#5f3c9a1e7b2d4c6a8e0f1b3d5c7e9a1b2c4d6e8f"

The mirror’s setup is in mirroring third-party repositories for resilience.

What happens on install when upstream changesWith a branch reference, a new push silently changes what installs. With a re-created tag, the change is equally silent. With a full-hash pin, an upstream change has no effect, and an upstream deletion makes the install fail loudly so someone notices.Upstream changed — what does install do?branch referenceNew code, silentlyno review happenedre-created tagNew code, silentlyharder to spotfull hash pinSame code, or failsnever a surprisefail-closed is the property you want from a supply-chain control

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Isn’t a lockfile enough? Jump to heading

A lockfile records what was resolved last time, and most package managers respect it. But a fresh resolve — a lockfile regenerated after a conflict, a new environment without one — goes back to the manifest. Pinning in the manifest makes both agree.

Should we pin to hashes for registry packages too? Jump to heading

Registry packages are pinned by exact version plus the lockfile’s integrity hash, which serves the same purpose. The extra risk with Git dependencies is that they bypass the registry’s immutability guarantees, which is why they need explicit hashes.

What about Go modules? Jump to heading

Go’s go.sum records content hashes for every module version, and pseudo-versions for untagged commits embed the commit hash. The risk shows up mainly in replace directives pointing at a fork’s branch; pin those to a pseudo-version or commit.