Submodule & Dependency Integrity Jump to heading
Signing your own commits establishes who wrote the code in your repository. It says nothing about the code your repository includes β the submodule pinned three years ago, the vendored library someone patched during an incident, the dependency directory nobody has looked at since it was added. That gap is where supply-chain problems live, and it is the natural extension of Commit Signing & Git Supply-Chain Security: the same question of provenance, applied to code you did not write.
Gitβs answer for included repositories is the submodule, and it is a good one that is widely misunderstood. A submodule entry in the superproject is not a copy of the dependency and not a version range β it is a single commit SHA, recorded in the tree, and that immutability is the whole security property. What it does not give you is any assurance that the commit you pinned was trustworthy when you pinned it. Pinning and verifying are different jobs, and both are needed.
Prerequisites Jump to heading
Step 1 β Understand What the Pin Actually Guarantees Jump to heading
The two red boxes are why βwe pin our dependenciesβ is a partial answer. Pinning converts an implicit, continuously-updating trust decision into an explicit, dated one β which is a real improvement β but the decision still has to be made, and that is Step 3βs job.
Step 2 β Add a Submodule With an Explicit, Reviewed Pin Jump to heading
# Add the dependency; Git records the current tip of the named branch
git submodule add --branch v3.2.1 https://example.com/upstream/libfoo.git vendor/libfoo
# Inspect what was actually recorded
cat .gitmodules
git ls-files --stage vendor/libfoo # mode 160000 = gitlink, followed by the SHA # The recorded SHA β this is the only thing that governs your build
git rev-parse HEAD:vendor/libfoo
git -C vendor/libfoo log --oneline -1 Note that --branch records a preference for future updates, not a floating reference. The build always uses the gitlink SHA. This surprises people who expect a branch name in .gitmodules to mean βtrack this branchβ β it does not, and that is a feature.
Write the provenance into the commit message, because the diff cannot carry it:
git commit -m "deps: vendor libfoo v3.2.1
Upstream: https://example.com/upstream/libfoo
Tag: v3.2.1
Commit: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
Signed by: [email protected] (verified against allowed_signers)
Reason: required for the new settlement API" Step 3 β Verify the Pinned Commit Before Trusting It Jump to heading
# Is the pinned commit signed, and by whom?
git -C vendor/libfoo verify-commit HEAD
# For a tagged release, verify the tag object itself
git -C vendor/libfoo verify-tag v3.2.1
# Which key signed it, checked against your trust anchor
git -C vendor/libfoo log -1 --format='%G? %GS %GK'
# G = good signature, U = good but untrusted, N = none, B = bad # Fail loudly rather than silently accepting an unsigned dependency
status=$(git -C vendor/libfoo log -1 --format='%G?')
[ "$status" = "G" ] || { echo "unverified dependency pin: $status" >&2; exit 1; } That last snippet belongs in CI, where it runs on every pull request that changes a gitlink. The mechanics of expressing βthis signature must be from someone we trustβ are the same as for your own commits, and are covered in Commit Verification Gates β the only difference is whose keys populate the allowed_signers file.
SAFETY WARNING β an unsigned upstream is common and is not automatically disqualifying, but it must be a recorded decision rather than an unnoticed default. If a dependency cannot be verified cryptographically, mirror it into a repository you control, record the exact SHA and a content digest, and review changes on upgrade. Silently depending on an unverifiable third party is the state most supply-chain incidents begin in.
Step 4 β Update Deliberately, Never Automatically Jump to heading
# 1. Fetch upstream without moving the pin
git -C vendor/libfoo fetch origin
# 2. Read exactly what would change
git -C vendor/libfoo log --oneline HEAD..origin/main
git -C vendor/libfoo diff HEAD..origin/main --stat
# 3. Verify the target before adopting it
git -C vendor/libfoo verify-commit origin/main
# 4. Move the pin only after the above
git -C vendor/libfoo checkout <verified-sha>
git add vendor/libfoo Steps 1 and 2 are what git submodule update --remote skips. The convenience command produces a one-line diff that no reviewer can evaluate β which converts dependency review into a rubber stamp.
Step 5 β Detect Drift in Vendored Directories Jump to heading
Vendored code has the opposite failure mode: it cannot change under you, but it can be edited by you and forgotten. A recorded digest turns that into a detectable event.
# Record provenance next to the vendored tree
cat > vendor/libfoo/UPSTREAM <<'EOF'
url: https://example.com/upstream/libfoo
tag: v3.2.1
commit: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
EOF
# Record a content digest of the tree as vendored
git ls-files -s vendor/libfoo | sha256sum > vendor/libfoo/.tree-digest
git add vendor/libfoo/UPSTREAM vendor/libfoo/.tree-digest # The CI check: re-derive the digest and compare
current=$(git ls-files -s vendor/libfoo | grep -v '.tree-digest' | sha256sum)
recorded=$(cat vendor/libfoo/.tree-digest)
[ "$current" = "$recorded" ] || {
echo "vendored tree differs from the recorded digest β patch it deliberately or restore it" >&2
exit 1
} A failing check is not necessarily a problem: local patches to vendored code are sometimes correct. What matters is that they become visible, get reviewed, and are recorded β usually as a patch file alongside the vendored tree, so the next upgrade knows what has to be reapplied.
Where the digest should live Jump to heading
Keep the digest inside the vendored directory rather than in a central manifest. A single manifest listing every dependency becomes a merge-conflict magnet the moment two teams update two different dependencies in the same week, and the conflict is between unrelated lines that a resolver has no way to reason about. One file per vendored tree conflicts only when the same tree is touched twice, which is a genuine conflict worth a human decision. The same argument applies to the UPSTREAM file: colocating provenance with the code means a directory move carries its provenance along automatically, and a directory deletion removes it rather than leaving an orphaned manifest entry that outlives the dependency by years.
Configuration Reference Jump to heading
| Setting | Default | Effect | When to change |
|---|---|---|---|
submodule.recurse | false | Recurse into submodules for most commands | Set true when submodules are integral to the build |
submodule.<name>.update | checkout | How update reconciles the working tree | Leave at checkout; merge and rebase hide the pin |
submodule.<name>.branch | unset | Which branch --remote follows | Set for dependencies you intend to update regularly |
diff.submodule | short | How submodule changes appear in diffs | log shows commit subjects, making review possible |
status.submoduleSummary | false | Show submodule changes in git status | Enable so an accidental pin move is noticed |
protocol.file.allow | user | Whether file:// submodules are permitted | Leave restricted; a permissive value has been exploited |
fetch.recurseSubmodules | on-demand | Fetch submodule objects during fetch | on-demand is usually right; no for very large dependencies |
Two rows there change review quality more than anything else in this guide. diff.submodule=log turns a two-hex-string diff into a list of upstream commit subjects, and status.submoduleSummary=true means a stray pin move is visible before it is committed rather than after it is merged.
Common Failure Modes and Diagnostics Jump to heading
A fresh clone builds without the dependency. Symptom: an empty directory where the submodule should be. Root cause: git clone does not populate submodules by default. Fix: git clone --recurse-submodules, or git submodule update --init --recursive afterwards; document whichever you standardise on.
The pin moves in commits nobody intended. Symptom: unrelated pull requests contain a gitlink change. Root cause: someone ran a command inside the submodule that moved its HEAD, then committed everything with git commit -a. Fix: enable status.submoduleSummary so it is visible, and treat a stray gitlink change as a review blocker.
CI builds a different dependency version from developers. Symptom: works locally, fails in CI or vice versa. Root cause: CI clones the submodule branch tip rather than the pinned SHA. Fix: check out the SHA from the index, and fail the job if it differs from what the superproject records.
The upstream repository disappears. Symptom: clones fail for everyone at once. Root cause: a dependency on a third-party URL with no mirror. Fix: mirror every external submodule into a repository you control, and point .gitmodules at the mirror β the pinned SHA is unchanged, so nothing else moves.
A vendored patch is lost during an upgrade. Symptom: a bug you fixed a year ago reappears after a dependency bump. Root cause: a local edit with no record. Fix: the digest check from Step 5, plus keeping local changes as patch files that must be reapplied and re-verified on each upgrade.
Team Rollout Jump to heading
Frequently Asked Questions Jump to heading
Does a submodule pin protect against a compromised upstream? Jump to heading
Partly. The pin names an exact commit, so an attacker who pushes new commits upstream cannot change what you build β your superproject still references the old SHA. What the pin does not protect against is the commit having been malicious when you pinned it, or a force-push that replaces the SHAβs content on a server that permits it. Pinning is necessary; verifying what you pinned is what makes it sufficient.
Submodules or vendoring β which is safer? Jump to heading
Submodules keep provenance: the SHA states exactly which upstream commit you use, and updating is an explicit, reviewable change. Vendoring keeps availability: the code is in your repository and builds even if upstream disappears, but its provenance is only as good as the process that copied it. The safest arrangement is vendoring with a recorded upstream SHA and an automated drift check, which is the pattern this guide describes.
Why did my submodule change appear as a one-line diff? Jump to heading
Because that is all a submodule is in the superproject: a gitlink entry recording one SHA. A reviewer sees the old and new SHAs and nothing about what changed between them, which is why a submodule bump needs the upstream range in its commit message. Without it, review is theatre β nobody can tell a patch release from an unrelated rewrite.
Should CI clone submodules recursively by default? Jump to heading
Only where the build needs them, and always at the pinned SHA rather than a branch tip. A recursive clone that follows branches turns a pinned dependency into a moving one and reintroduces exactly the non-reproducibility submodules exist to prevent. Fetch shallowly at the recorded SHA, and fail the job if the checked-out SHA differs from the one in the index.
How do I know a vendored directory has not been edited locally? Jump to heading
Record the upstream SHA and a content digest next to the vendored tree, then re-derive both in CI and compare. A local edit β however well-intentioned β changes the digest and fails the check, which is exactly what you want: patches to vendored code should be explicit, reviewed, and documented rather than discovered a year later during an upgrade.
Related Jump to heading
- Pinning and Updating Git Submodules Safely β the day-to-day mechanics, including how to make a pin bump reviewable.
- Converting a Submodule to a Subtree β when availability matters more than provenance, and how to keep both.
- Auditing Vendored Dependencies for Tampering β the digest and provenance checks that turn drift into a failing build.
- Commit Verification Gates β the same trust machinery applied to your own commits, and where upstream keys belong.
- Protecting & Rotating Signing Keys β custody of the keys that make any of this verification meaningful.