Licence policy as code
Most teams have a licence policy somewhere: a wiki page, a line in the security questionnaire, a lawyer’s email. CI can only enforce it if it lives in the repo, next to the lockfile, and changes through pull requests like everything else.
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.
Why the policy belongs in the repo
A policy that lives in a document is checked when someone remembers to check it, usually during a due diligence or a customer’s security review. A policy file in the repo is checked on every pull request, and every change to it shows up in review with an author and a date. That history is what a buyer or an auditor asks for: not only what the rules are, but when an exception was granted and why.
What the common tools let you write
| Tool | Allow / deny | Exceptions |
|---|---|---|
| license-checker | --onlyAllow and --failOn, semicolon-separated licence names on the command line | --excludePackages with name@version entries; no field for a reason or an end date |
| GitHub dependency review action | allow-licenses (SPDX ids) on pull requests; deny-licenses is marked deprecated for possible removal | allow-dependencies-licenses takes package URLs; no reason or end date |
| licensegate | allow and deny in .licensegate.json, with SPDX ids or whole categories | Per package, optionally a version range, with a required reason and expiry date |
Command-line flags work, but they end up in a CI file with no room to explain them. Six months later nobody knows why left-pad@1.0.0 was excluded, or whether that decision still holds.
A policy file that explains itself
{
"deny": ["AGPL-3.0-only", "AGPL-3.0-or-later", "SSPL-1.0"],
"allow": ["MPL-2.0"],
"exceptions": [
{
"package": "some-db-driver",
"version": "^2.0.0",
"reason": "runs as a separate, unmodified service; reviewed by legal 2026-10-01",
"expires": "2027-03-31"
}
]
}
- allow and deny take SPDX ids or categories:
permissive,weak,strong,network,restrictedandunknown. They override what the--modewould decide on its own. - A rule on an exact id beats a rule on its category, so you can deny
weakand still allowMPL-2.0. At the same level, deny wins. - An exception clears one package, and a
versionrange like^2.0.0keeps it from silently covering a future major version. reasonandexpiresare required. After the expiry date the exception stops applying and the run prints a warning, so a waiver comes back for review instead of living forever. An exception that matches no package is reported too, which catches typos and packages you already removed.
Run it
npx -y https://licensegate.org/dl/licensegate.tgz --mode saas --prod
licensegate picks up .licensegate.json from the current folder, or from the file given with --policy. A malformed file stops the run with exit code 2 and the line and column of the problem, rather than passing with no policy. Every output says when the policy made the decision and why: the text summary, the Markdown comment for pull requests, SARIF for code scanning (cleared findings become suppressions that carry your reason), JUnit and JSON.
In a pull request
- run: npm ci
- run: npx -y https://licensegate.org/dl/licensegate.tgz --mode saas --prod --diff main --format markdown --output licences.md
With --diff, only packages that are new or changed their licence since main can fail the job, so an old exception doesn’t block unrelated work. A change to .licensegate.json itself goes through the same review as code: the reviewer sees the new exception, its reason and its expiry date in the diff.
When someone outside asks
A buyer or an enterprise customer will want more than the file: they want to know the policy was applied to the code they are buying on a given date. A sealed report records the policy that was in force, each exception with its reason and the packages it cleared, and an Ed25519 seal that breaks if anyone edits the report, including the wording of a reason.