Software Engineering
Git Submodules in CI: Check Out the Commit the Parent Pinned
Fix missing or stale Git submodules in CI by separating parent gitlinks, local checkouts and remote access, then verifying the exact pinned commit.
In this article
A Git parent repository records a submodule commit, but checking out the parent does not always populate the corresponding submodule working tree. CI can therefore have the correct parent revision and still build missing, stale, or unexpectedly updated dependency code.
The goal is straightforward: build the exact submodule commit recorded by the parent. Use the official git-submodule documentation for your installed Git version and treat remote-tracking updates as a separate maintenance action, not part of an ordinary reproducible build.
Understand the three pieces of state
The parent stores a gitlink identifying a commit in another repository. The .gitmodules file describes submodule paths and URLs. The local submodule working tree may or may not currently contain the pinned commit.
Those pieces can disagree without the parent itself appearing obviously broken. A developer may have a locally initialized submodule from last week, while a clean CI runner has no submodule content at all. A cached runner can be even more confusing because it may contain a different commit.
When diagnosing a failure, record the parent commit, submodule path, pinned commit, and actual checked-out commit. “The repository is on main” is not enough information to reproduce a submodule build.
Reproduce from a clean checkout
In an isolated directory with access to repositories you are authorized to use, clone the parent and populate its recorded submodules:
git clone <authorized-parent-url> project
cd project
git submodule update --init --recursive
git submodule status --recursiveThe placeholder URL is not a real destination. Review the repository and .gitmodules before executing commands from an unfamiliar project. Submodules can point to additional repositories, and build scripts can execute their contents.
Do not use a developer’s populated workspace as the only evidence that CI should work. A clean clone reveals missing initialization, inaccessible URLs, and commits unavailable from the expected remote.
Read the status, then verify the commit
Submodule status output uses prefixes to distinguish conditions such as uninitialized or mismatched checkouts. Read the documented meanings rather than stripping the prefix and assuming the hash proves success.
For a specific path, compare the parent’s recorded entry with the submodule’s actual HEAD:
git ls-tree HEAD -- path/to/component
git -C path/to/component rev-parse HEADThe first command identifies what the parent expects. The second identifies what is present locally. Keep the path literal and quoted appropriately if it contains spaces. A build should fail clearly when the expected commit cannot be obtained, not silently substitute a nearby branch tip.
Keep remote updates out of routine builds
An update following a configured remote branch answers “What is current upstream?” rather than “What did this parent commit pin?” Mixing that behavior into CI can make yesterday’s parent commit produce different binaries today.
If maintainers want to update a dependency, do it in a reviewed change: obtain the intended upstream commit, test it, and commit the new gitlink in the parent. Review the dependency diff as well as the parent’s one-line hash change.
Do not interpret a detached HEAD inside a pinned submodule as automatically broken. A commit checkout is normal for a reproducible dependency. Creating a branch merely to remove that visual warning can introduce unnecessary state.
Diagnose access and URL changes separately
A private submodule may require permissions that the parent checkout credential does not possess. Confirm the CI identity’s approved access to each dependency. Avoid embedding tokens in .gitmodules, command logs, or rewritten URLs committed to the repository.
When a legitimate submodule URL changes, review the new destination and use the supported synchronization workflow for local configuration. Do not blindly synchronize URLs from an untrusted pull request before evaluating whether that workflow has privileged credentials.
Check nested submodules too. Initializing only the first level can leave a deeper dependency missing. The recursive flag is useful when nested dependencies are expected and reviewed; it should not replace knowing what repositories the build consumes.
Make caches prove their identity
Cache keys should account for the parent revision or the relevant pinned submodule commits, not only the parent branch name. After restoring a cache, still verify the working tree against the expected gitlinks.
If local modifications are present inside a submodule, stop and investigate before forcing a reset. Developer changes belong to their author, and a cached CI workspace may contain evidence of a faulty build step. Destructive cleanup is not a substitute for understanding why state changed.
For release builds, save a manifest of the parent and all submodule commit IDs. That manifest lets another environment check the exact source combination rather than reconstructing it from mutable branch names.
Check that the dependency commit remains available from the approved remote before releasing the parent revision. A local-only commit can work on its author’s machine and still make every clean checkout fail.
Conclusion
Initialize submodules deliberately, compare actual HEADs with parent gitlinks, and keep upstream updates separate from ordinary builds. A pinned dependency is reproducible only when CI proves it checked out the pinned commit.