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.

Which variables define the range in GitLab CIFor merge request pipelines the diff base SHA marks where the merge request starts. For branch pushes the before SHA marks the previous tip, which is all zeros for a new branch. Merged-results pipelines run on a temporary merge commit, whose first parent is the target branch.Base of the rangeHead of the rangemerge_request_eventCI_MERGE_REQUEST_DIFF_BASE_SHACI_COMMIT_SHApush (existing branch)CI_COMMIT_BEFORE_SHACI_COMMIT_SHApush (new branch)default branch tipCI_COMMIT_SHAmerged resultsHEAD^1HEAD^2a merged-results pipeline needs the second parent, not the merge commit itself
# 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
The verification job end to endThe job clones with full history, computes the range from the pipeline source, loads the trust file from the target branch, checks every commit's signature status and reports failures as a job log and a merge request note.CloneGIT_DEPTH=0Rangeby pipeline sourceTrust filefrom target branchVerify%G? per commitReportlog + MR notethe target-branch trust file is what stops a merge request from vouching for itself

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
GitLab controls, from coarsest to finestA push rule rejects commits without a verified signature at push time. The pipeline requirement blocks merging until the verification job passes. The job itself checks the signer against the team trust file, which is the only layer that knows who belongs.each layer adds a question the previous one cannot askPush ruleis there a verified signature?Pipeline must succeeddid the job pass?Verification jobis the signer on our team?the job is the only layer that reads your trust file

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.