A licence check in GitLab CI and Bitbucket Pipelines
GitLab’s own licence scanning is an Ultimate feature, and Bitbucket Pipelines has none built in. On any tier, a job that runs a licence checker and writes a JUnit report gets you most of the way: the pipeline fails, and the merge request says which package and why.
This page explains how these licences are commonly read. It is not legal advice; for a product decision with money on it, ask a lawyer who handles open source.
What each platform gives you
| Built-in licence check | Where results show | |
|---|---|---|
| GitLab Free or Premium | None since the License-Scanning.gitlab-ci.yml template was removed in GitLab 17.0 | Unit test reports (all tiers) in the merge request |
| GitLab Ultimate | Licence scanning of CycloneDX files, fed by dependency scanning, and licence approval policies that block the merge request | The merge request and the dependency list |
| Bitbucket Pipelines | None in the Pipelines documentation; third-party pipes from the marketplace, or a command in a step | Test reporting for JUnit XML in the Tests tab |
The Ultimate route has real advantages: a policy enforced at the project or group level, approvals by named people, and licences pulled from GitLab’s own package metadata. If you don’t have Ultimate, you need two things it would have given you: a job that fails when a dependency’s licence is not acceptable, and a place where the merge request author can see why without opening the job log.
GitLab CI: one job, any tier
npx -y https://licensegate.org/dl/licensegate.tgz init ci gitlab --mode distributed --prod
That writes the job to its own file and, if the project has no .gitlab-ci.yml yet, one that includes it. If you already have one, it leaves it alone and prints the two lines to add:
.gitlab/licensegate.gitlab-ci.ymllicensegate:
stage: test
image: node:24
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- npm ci
- npx -y https://licensegate.org/dl/licensegate.tgz --mode distributed --prod --format junit --output licensegate-junit.xml --sbom licensegate.cdx.json
artifacts:
when: always
paths:
- licensegate.cdx.json
reports:
junit: licensegate-junit.xml- The rules run the job on merge requests and on the default branch, the same pair GitLab uses in its own workflow examples.
- Exit code 1 when a package blocks your
--modefails the job, so a pipeline that must pass before merging stops the merge request. reports: junitturns each package into a test case. A blocked package is a failed test with the reason as its message, and that shows up in the merge request’s test summary. GitLab uploads report artifacts whatever the job result;when: alwayskeeps the SBOM too.- The install line follows your lockfile:
npm ci, or pnpm, yarn or bun through corepack or npm. licensegate reads the lockfile andnode_modules, so the install has to come first.
This is what lands in the report for a desktop app with a policy file. Two failures, and the two packages a policy cleared say so:
licensegate-junit.xml · trimmed<testsuites name="licensegate" tests="6" failures="2" errors="0" skipped="0" time="0">
<testsuite name="licensegate --mode distributed" tests="6" failures="2" ...>
<testcase name="doc-store@6.0.1" classname="licences.network" file="package-lock.json" time="0">
<failure type="block" message="doc-store@6.0.1 is SSPL-1.0 (network): ... It blocks: denied by policy (.licensegate.json deny: SSPL-1.0).">...</failure>
</testcase>
<testcase name="rich-editor@3.1.0" classname="licences.strong" file="package-lock.json" time="0">
<failure type="block" message="rich-editor@3.1.0 is GPL-3.0-only (strong): ... It blocks --mode distributed.">...</failure>
</testcase>
<testcase name="express@4.21.2" classname="licences.permissive" file="package-lock.json" time="0"/>
<testcase name="geo-utils@1.2.0" classname="licences.weak" file="package-lock.json" time="0">
<system-out>... Otherwise it would need review for --mode distributed; it is allowed by policy (.licensegate.json allow: MPL-2.0).</system-out>
</testcase>Packages that need a person (non-commercial licences, unknown ones, an LGPL library in an app you ship) are skipped tests by default, so they show without failing the job. Add --fail-on review to make them fail too.
Bitbucket Pipelines
npx -y https://licensegate.org/dl/licensegate.tgz init ci bitbucket --mode distributed --prod
bitbucket-pipelines.ymlimage: node:24
pipelines:
default:
- step:
name: Licence check (licensegate)
script:
- npm ci
- npx -y https://licensegate.org/dl/licensegate.tgz --mode distributed --prod --format junit --output test-results/licensegate.xml --sbom licensegate.cdx.json
artifacts:
- licensegate.cdx.jsonBitbucket looks for JUnit XML in folders named test-results, test-reports and a few others, up to three levels deep, so the report needs no extra configuration. The default pipeline runs on every push to a branch that has no pipeline of its own, which includes the branches behind pull requests. A repository has one bitbucket-pipelines.yml: if yours exists, the command doesn’t touch it and prints the step to paste into it.
Before the commit
CI catches the problem after the push. A git hook catches it on the developer’s machine, when the lockfile is committed:
npx -y https://licensegate.org/dl/licensegate.tgz init hook --mode distributed --prod
git commit -m "add rich-editor" · exit code 1licensegate 0.7.0 · mode distributed · production only · 4 packages from package-lock.json
diff against HEAD (e0949b9e48ca): 2 new or changed
BLOCK rich-editor@3.1.0 GPL-3.0-only (new)
strong copyleft: shipping it inside your program can require releasing your program under the same licence
ok tiny-hash@1.0.3 MIT-0 (new)
1 ok · 0 to review · 1 blocking
Not legal advice. Grey cases (linking, plug-ins, dual licences) say REVIEW on purpose.
licensegate: licence check failed for package-lock.json (see above). Skip once with git commit --no-verify.The hook runs only when a lockfile is staged, and checks only the packages that are new or changed their licence since HEAD, so it stays quiet on ordinary commits and doesn’t block on an old dependency someone has already accepted. It is a convenience for the developer, not a control: --no-verify skips it, which is why the CI job stays.
What this doesn’t replace
- Approvals by named people. GitLab Ultimate can require a specific group to approve a merge request that adds a denied licence. A failing job can be fixed by anyone who edits the policy file; protect that file with code owners (Premium and up) if it matters.
- Group-wide rules. A
.licensegate.jsonlives in each repository. Keep a template and copy it, or include a shared CI file that passes--policy. - Other ecosystems. licensegate reads npm, pnpm, yarn and bun projects. A Python or Go service in the same repository needs its own check.