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.
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; } 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.
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.
Related Jump to heading
- Submodule & Dependency Integrity — the parent topic.
- Vendoring Dependencies with Git Subtree — the heavier alternative that removes the fetch entirely.
- Pinning GitHub Actions to a Commit SHA — the same idea for CI actions.
- Auditing Vendored Dependencies for Tampering — checking what a pinned hash actually contains.