Context
On GitLab, squash enforcement has to go through CI, because the native setting applies to the whole project. In Settings → Merge requests → Squash commits when merging, the Require option would also squash the develop → master release MRs, which must keep their history.
GitLab does have an advantage over GitHub: every MR carries a "Squash commits when merge request is accepted" checkbox, readable in CI through CI_MERGE_REQUEST_SQUASH_ON_MERGE and editable through the API. The three options below rely on jobs that only run for MRs into develop.
Shared prerequisite: enable Pipelines must succeed (Settings → Merge requests). Without it, a failed job shows a red cross but does not prevent the merge. Squashing must also be allowed (not set to "Do not allow").
Option 1 — Block when the squash box is unticked
The job reads the checkbox when the pipeline starts and fails if it is not ticked. The pipeline turns red, so the Merge button stays greyed out.
squash-required:
stage: .pre
image: public.ecr.aws/docker/library/alpine:3.20
script:
- |
if [ "$CI_MERGE_REQUEST_SQUASH_ON_MERGE" != "true" ]; then
echo "Merge requests into develop must be squashed: tick 'Squash commits when merge request is accepted', then run a new pipeline."
exit 1
fi
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
-
stage: .preruns the job before all others, so the failure shows up immediately. - To unblock, the developer ticks the box and starts a new pipeline. Simply retrying the job would reuse the old value of the variable.
- Limitation: the checkbox is only read at startup. Unticking it after a green pipeline allows a merge without squash.
Option 2 — Tick the box automatically through the API
Instead of failing, the job ticks the box on the MR with a call to the GitLab API. GitLab reads the option at merge time, so the squash will be applied. Without a token, or if the call fails, the job falls back to the blocking behaviour of option 1.
Setup:
- Create a project access token (Settings → Access tokens), role Developer, scope api, with a short expiry.
- Add it under Settings → CI/CD → Variables as
SQUASH_BOT_TOKEN, Masked but not Protected, otherwise feature branches do not receive it. - Replace the job with this one:
squash-required:
stage: .pre
image: public.ecr.aws/docker/library/alpine:3.20
script:
- |
if [ "$CI_MERGE_REQUEST_SQUASH_ON_MERGE" = "true" ]; then
exit 0
fi
if [ -n "$SQUASH_BOT_TOKEN" ] \
&& apk add --no-cache curl > /dev/null \
&& curl --fail -sS -X PUT \
--header "PRIVATE-TOKEN: $SQUASH_BOT_TOKEN" \
--data "squash=true" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID" > /dev/null
then
echo "Squash was not ticked: enabled automatically on !$CI_MERGE_REQUEST_IID."
exit 0
fi
echo "Merge requests into develop must be squashed: tick 'Squash commits when merge request is accepted', then run a new pipeline."
exit 1
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
The PUT call on /projects/:id/merge_requests/:iid with squash=true is the same as ticking the box in the UI; the CI_* variables fill in the URL automatically.
- No action left for the developer.
- The token is not protected: anyone who edits
.gitlab-ci.ymlin their branch can read it. The Developer role and a short expiry limit the risk. - MRs from forks do not receive the variable and fall back to blocking.
- The "untick after the pipeline" loophole remains open.
Option 3 — Require a single commit in the MR
The job counts the MR's commits and fails if there is more than one: the developer squashes before merging. This is the equivalent of the GitHub Action, and the only way to close the "untick afterwards" loophole, since the check runs again on every push.
single-commit:
stage: .pre
image: public.ecr.aws/docker/library/alpine:3.20
variables:
GIT_DEPTH: 0 # full history, so the MR base commit is available
script:
- apk add --no-cache git > /dev/null
- |
HEAD_SHA="${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA:-$CI_COMMIT_SHA}"
COMMITS=$(git rev-list --count "$CI_MERGE_REQUEST_DIFF_BASE_SHA..$HEAD_SHA")
if [ "$COMMITS" -ne 1 ]; then
echo "MRs into develop must contain a single commit (found $COMMITS). Squash it: git reset --soft origin/develop && git commit && git push --force-with-lease"
exit 1
fi
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
- GitLab does not expose the commit count as a variable: it is computed with
git rev-list, henceGIT_DEPTH: 0. -
CI_MERGE_REQUEST_SOURCE_BRANCH_SHAis needed with merged results pipelines, whereCI_COMMIT_SHAis a temporary merge commit. - Developers have to squash locally, then run
git push --force-with-lease.
Comparison and recommendation
Recommendation: option 2, which removes forgotten checkboxes with no effort from developers and falls back to option 1 without a token. Option 3 is only worth it if the "untick afterwards" loophole is a real problem for the team.
| Option 1 — Block | Option 2 — Tick via the API | Option 3 — Single commit | |
|---|---|---|---|
| Box forgotten | Merge blocked | Box ticked automatically | Not applicable |
| Developer effort | Tick, then rerun | None | Squash locally |
| Box unticked after the pipeline | Not detected | Not detected | Cannot be bypassed |
| Prerequisites | None | Token + CI variable | GIT_DEPTH: 0 |
| Added risk | None | Token readable by developers | None |
Difference with GitHub: GitHub can restrict the merge method per branch (ruleset), but has no squash checkbox readable in CI. GitLab is the opposite; only option 3 exists on both platforms.
See the Github: Require Squash Commits for Pull Requests to the “develop” Branch
Top comments (0)