Testing Submodules' Commit Path within Jenkins Pipeline
Submodules?
I’ve never been a massive fan of submodules as they come with their own set of complexities and surprises, and embed dependency management within your SCM when really that should be the job of your packaging tool. But sometimes they are the right tool for the job when other tools are absent or your deploy is atypical.
I’ve been using them recently to split out code that needed to be shared across repos but which wasn’t available to be distributed via a package repository of any kind. The shared code is available and versioned in one repo, but also present in multiple others simply by referring to the first repo as a ‘submodule’.
What are Submodules
When you create a submodule in your git repo, you are cloning another repo into a subfolder within the first one. The entire commit history of that repo doesn’t get pulled into your history however, just a little file called .gitmodules
which specifies the origin location and the commit id to which the submodule (sub repo) is pinned.
Example
Let’s say we have code in RepoA that we want available in RepoB. Let’s
cd ~git/repob/
and do
git submodule add git@github.com/RepoA remote_code
This pulls the code from RepoA into RepoB under the folder remote_code
and creates the .gitmodules
file which we can then commit in RepoB to pin the current commit in RepoB (the submodule) to be the contents of remote_code
.
When you want to update to a newer version (commit) of the submodule, you can
cd remote_code
(where you’ll find all the contents of RepoA), do
git pull master
to get the latest code and then
cd ..
back up a level into the parent, where RepoA has recognised the new commit that’s been pulled into remote_code
and allow you to commit an updated .gitmodules
that contains the new commit id!
Why test?
So far so good right? But what if we want to test some code that hasn’t been merged into master in RepoA (remote_code) with the code in RepoB? We
cd remote_code
do a
git checkout feature/my_new_code
then go off and write our updated code, do our tests, and commit and push the updated code along with the updated .gitmodules
file.
But… That code isn’t in master, we’re now pushing a dependency on unintegrated code from RepoA
into RepoB
! We don’t even know if this feature branch will get merged! Some poor unsuspecting person might want to use the latest code from RepoA’s master branch a few months down the line, and unwittingly break the code we’ve written that depended the unmerged feature branch!
It’s not even obvious that we’ve pinned our submodule to a commit from an unmerged feature branch when someone does a PR review on our code in RepoB, as .gitmodules
only shows the commit id, not the branch, so unless our reviewer is super vigilant, they may just assume we’ve updated to a more recent version of master!
Jenkins Tests
This is why we test! In Jenkins we need to do 3 things.
- Clone RepoB
- Update the submodule (RepoA)
- Get RepoA’s current commit sha
- Get RepoA’s master branch log history
- Check the current commit sha of RepoA from
remote_code
appears in RepoA’s master branch history
Clone RepoB
Okay you’re probably doing this as you’re (presumably) integrating this into your existing pipeline, but here’s an example.
git branch: 'master',
credentialsId: 'RepoB',
url: 'git@ithub.com:example/RepoB.git'
Update the submodule
withCredentials([sshUserPrivateKey(credentialsId: 'RepoB', keyFileVariable: 'GIT_SSH_KEY')]) {
sh """
export GIT_SSH_COMMAND='ssh -i $GIT_SSH_KEY'
cd build/sut
git submodule update --init --recursive
"""
}
It’s actually preferable to do this with the SSHAgent Plugin but the above will work without additional plugins (which can be hard to get if you don’t control the Jenkins box) assuming your SSH key doesn’t have a password (which it really should).
Get RepoA’s current commit SHA
def getCommitSha(path='remote_code/.git') {
sh """
git --git-dir=${path} rev-parse HEAD > .git/current-commit
"""
return readFile(".git/current-commit").trim()
}
The command git --git-dir=${path} rev-parse HEAD > .git/current-commit
gets the current commit from ${path}
(remote_code
) and pipes it to .git/current-commit
as a text file.
This is a Groovy function, so you can just paste it at the top of your pipeline file prior to any node{}
etc.
Get RepoA’s master commit history
def remoteCodeInMaster(){
sh """
git --git-dir=remote_code/.git log --format='%H' master > .git/remote_code_master_log
"""
def remoteCodeCommit = readFile(".git/remote_code_master_log").trim()
return remoteCodeCommit.contains(getCommitSha('build/sut/remote_code/.git'))
}
The command: git --git-dir=remote_code/.git log --format='%H' master
prints out a list of commit SHAs for the branch master in the remote_code
folder.
Final Step: Check the current commit SHA in remote_code is one that’s in the master branch
if(remoteCodeInMaster()){
echo("${getCommitSha()} found in remote_code and it's part of the Master branch!")
}else{
error("remote_code is pinned to a commit ('${getCommitSha()}') which is not in the master branch of RepoA")
}
This goes inside one of your steps and calls the previous two methods we defined in order to fail the Jenkins job if the current commit SHA in RepoA (remote_code
) is one that’s in the master branch.
Putting it all together
The above all put together in one job looks like so:
def getCommitSha(path='remote_code/.git') {
sh """
git --git-dir=${path} rev-parse HEAD > .git/current-commit
"""
return readFile(".git/current-commit").trim()
}
def remoteCodeInMaster(){
sh """
git --git-dir=remote_code/.git log --format='%H' master > .git/remote_code_master_log
"""
def remoteCodeCommit = readFile(".git/remote_code_master_log").trim()
return remoteCodeCommit.contains(getCommitSha('build/sut/remote_code/.git'))
}
node {
stage('Preparation'){
git branch: 'master',
credentialsId: 'RepoB',
url: 'git@ithub.com:example/RepoB.git'
// Update submodules
withCredentials([sshUserPrivateKey(credentialsId: 'RepoB', keyFileVariable: 'GIT_SSH_KEY')]) {
sh """
export GIT_SSH_COMMAND='ssh -i $GIT_SSH_KEY'
cd build/sut
git submodule update --init --recursive
"""
}
}
stage('Test') {
if(remoteCodeInMaster()){
echo("${getCommitSha()} found in remote_code and it's part of the Master branch!")
}else{
error("remote_code is pinned to a commit ('${getCommitSha()}') which is not in the master branch of RepoA")
}
}
}