licensegate

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.

Install licensegate

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 checkWhere results show
GitLab Free or PremiumNone since the License-Scanning.gitlab-ci.yml template was removed in GitLab 17.0Unit test reports (all tiers) in the merge request
GitLab UltimateLicence scanning of CycloneDX files, fed by dependency scanning, and licence approval policies that block the merge requestThe merge request and the dependency list
Bitbucket PipelinesNone in the Pipelines documentation; third-party pipes from the marketplace, or a command in a stepTest 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.yml
licensegate:
  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 --mode fails the job, so a pipeline that must pass before merging stops the merge request.
  • reports: junit turns 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: always keeps the SBOM too.
  • The install line follows your lockfile: npm ci, or pnpm, yarn or bun through corepack or npm. licensegate reads the lockfile and node_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.yml
image: 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.json

Bitbucket 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 1
licensegate 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.json lives 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.