Verifying signed commits in GitLab CI Jump to heading
GitLab can reject unsigned commits with a push rule, but that rule checks only that a signature exists and is verified against keys users uploaded β it cannot tell whether the signer belongs to your team, and on some tiers it is not available at all. A CI job gives you full control: your own trust file, your own policy, and output that lands in the merge request. The complications are GitLab-specific: shallow clones by default, a different variable for the base of each pipeline source, and detached merge-request pipelines that run on a commit that does not exist on any branch. This page handles each, as a GitLab counterpart to verifying signed commits in GitHub Actions, within signed commits in CI pipelines.
When to use this approach Jump to heading
- Your repositories are hosted on GitLab, self-managed or SaaS.
- You want signature checks that use a team trust file rather than βany verified keyβ.
- Merge requests should show the signature result before merge, not only at push time.
- You may also use push rules; this job complements them by checking who signed, not just whether something did. Push rules themselves are covered in GitLab push rules and server hooks.
Step 1 β Fetch enough history Jump to heading
GitLab clones with a shallow depth by default (GIT_DEPTH, typically 20 or 50). A merge request with more commits than that, or a branch whose merge base is older, gives an incomplete range. Set the depth to zero for this job.
verify-signatures:
stage: .pre
image: alpine:3.20
variables:
GIT_DEPTH: "0" # full history for this job only
GIT_STRATEGY: clone
before_script:
- apk add --no-cache git openssh-keygen Putting the job in the .pre stage makes it run before everything else, so a signature failure is the first thing a contributor sees.
Step 2 β Pick the range for each pipeline source Jump to heading
GitLab exposes different variables depending on why the pipeline is running. The job must choose the right base for each.
# ci/gitlab-range.sh β prints the range to verify
zero=0000000000000000000000000000000000000000
case "$CI_PIPELINE_SOURCE" in
merge_request_event)
if [ "$(git rev-list --parents -n1 HEAD | wc -w)" -gt 2 ]; then
echo "HEAD^1..HEAD^2" # merged-results pipeline
else
echo "$CI_MERGE_REQUEST_DIFF_BASE_SHA..$CI_COMMIT_SHA"
fi ;;
push)
if [ "$CI_COMMIT_BEFORE_SHA" = "$zero" ]; then
git fetch -q origin "$CI_DEFAULT_BRANCH"
echo "origin/$CI_DEFAULT_BRANCH..$CI_COMMIT_SHA"
else
echo "$CI_COMMIT_BEFORE_SHA..$CI_COMMIT_SHA"
fi ;;
*) echo "$CI_COMMIT_SHA^!" ;; # schedules etc.: just the tip
esac Step 3 β Read the trust file from the target branch Jump to heading
Do not read the trust file from the merge requestβs tree; a contributor could add their own key in the same merge request. Read it from the target branch, or from a dedicated repository at a pinned revision.
target=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-$CI_DEFAULT_BRANCH}
git fetch -q origin "$target"
git show "origin/$target:.gitlab/allowed_signers" > /tmp/allowed_signers
git config gpg.ssh.allowedSignersFile /tmp/allowed_signers Step 4 β Verify every commit and report clearly Jump to heading
Loop over the range, collect failures, and print them in a form a contributor can act on.
script:
- range=$(sh ci/gitlab-range.sh)
- echo "Verifying $range"
- |
fail=0
for c in $(git rev-list $range); do
st=$(git log -1 --format='%G?' "$c")
if [ "$st" != G ]; then
echo "β $(git log -1 --format='%h %ce: %s' "$c") [status $st]"
fail=1
fi
done
[ "$fail" -eq 0 ] || { echo "Sign the commits above and force-push the branch."; exit 1; }
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH GPG-signed commits need the trusted public keys imported into a keyring in the job as well; SSH-signed commits need only the trust file.
# Verification: an unsigned commit in a test merge request fails the job
git commit --allow-empty --no-gpg-sign -m "unsigned probe" && git push origin HEAD
glab ci status --live Step 5 β Make the job block merging Jump to heading
A failing job only blocks merging if the project requires pipelines to succeed. Turn that on, and make sure the job is not marked allow_failure.
# Require a successful pipeline before merge (project setting)
glab api -X PUT "projects/$CI_PROJECT_ID" -f only_allow_merge_if_pipeline_succeeds=true
glab api "projects/$CI_PROJECT_ID" | jq .only_allow_merge_if_pipeline_succeeds Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not rely on GitLabβs push rule alone? Jump to heading
The push rule checks that a signature verifies against a key some GitLab user uploaded. It cannot restrict that to your team, cannot express validity windows, and on some tiers is unavailable. The job adds those properties.
Does the job slow down every pipeline? Jump to heading
A full clone is the main cost. On large repositories, use a cached mirror on your runners or a partial clone with --filter=blob:none, which fetches commits and trees without file contents β enough for signature verification.
What about commits GitLab creates itself, such as merge commits? Jump to heading
GitLab can sign the commits it creates if an administrator configures a signing key. Add that key to your trust file under its own principal; otherwise the job will flag every merge commit on the default branch.
Related Jump to heading
- Signed Commits in CI Pipelines β the parent topic.
- Verifying a Range of Commits in CI, Not Just the Tip β the forge-neutral version of the range logic.
- Mapping GitLab CI Rules to Branch and Path Changes β controlling when this job runs.
- GitLab Merge Trains β where merged-results pipelines come from.