Gating Merges on a License Compatibility Check in GitHub Actions

A license compatibility check in GitHub Actions is a required status check that reads every dependency's declared license, evaluates the resulting SPDX expression against a policy file, and fails the pull request when the combination cannot be shipped. It catches declared dependencies. It does not catch code a developer pasted into a file from a GitHub gist, and that distinction is where most compliance programs quietly fall apart.

The gate at a 240-person payments company in Denver has blocked eleven pull requests since February. Nine were legitimate. A charting library pulled GPL-3.0 in transitively through a fork nobody had audited. An Apache-2.0 package had been vendored into a repo that ships under MIT, with the NOTICE file deleted during the copy. A third case was a minor-version bump that relicensed the package, a pattern engineers now recognize from HashiCorp's move to the BUSL in August 2023 and Redis's shift to RSALv2 and SSPLv1 in March 2024. The other two blocks were false positives caused by a package with no license field in its manifest and no LICENSE file in the tarball.

Eleven blocks out of roughly 1,400 pull requests per quarter is a low signal rate. That is the point. The gate exists to be boring.

What a license compatibility check in GitHub Actions actually catches

Three distinct problems get filed under the same heading, and conflating them is the most common design mistake. The first is dependency licensing: what does the package tree obligate the company to do. The second is file-level header hygiene: which files in the repository have lost their attribution. The third is provenance: where did this code come from before it landed in a commit.

Tools overlap across the three, but none covers all of them. Licensee, the Ruby gem GitHub uses to populate the license badge on a repository, reads a LICENSE file and returns an SPDX identifier with a confidence score. It tells you almost nothing about the 900 packages underneath. Syft and the OSS Review Toolkit build SBOMs and apply policy. ScanCode Toolkit, at version 32.x, does license and copyright detection with a scan that will happily walk a vendored directory and surface the two dozen license headers a human reviewer would miss. The REUSE tool from FSFE enforces a convention: every file carries an SPDX header, and a LICENSES directory holds the texts, so that reuse lint returns a binary answer.

"The dependency scanner tells you what you downloaded. It does not tell you what you typed," said Priya Raghunathan, a staff engineer who built the compliance pipeline at a healthcare data platform in Boston. "Those are different risk profiles and they need different jobs in the workflow."

The workflow: SPDX expressions first, headers second

The dependency job is the one that has to be fast, because it runs on every pull request. It does not need an SBOM. It needs a list of resolved licenses and a policy.

name: license-gate
on: [pull_request]
jobs:
  spdx-policy:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm install --no-save [email protected]
      - name: Resolve production license expressions
        run: npx licensee --production --json > licenses.json
      - name: Evaluate against policy
        run: python3 tools/license_gate.py licenses.json

The evaluation script is short, and the short version is where teams get it wrong. Comparing strings against a set of approved identifiers fails the moment a package declares (MIT OR Apache-2.0), which is a choice the consumer gets to make, or GPL-2.0-only WITH Classpath-exception-2.0, which is a different license from GPL-2.0-only in the way that matters.

from spdx.parser import parse  # spdx-tools 0.8.x

ALLOW = {"MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD"}
CONDITIONAL = {"MPL-2.0", "LGPL-3.0-only", "EPL-2.0"}  # needs a recorded exception

def evaluate(expr, package):
    parsed = parse(expr)
    options = parsed.to_disjunction()  # (MIT OR Apache-2.0) gives the consumer a choice
    for option in options:
        licenses = set(option.license_ids())
        if licenses <= ALLOW:
            return "pass"
        if licenses <= (ALLOW | CONDITIONAL) and exception_exists(package):
            return "pass-with-exception"
    return "fail"

Parsing instead of string matching is what makes OR work correctly. A team that ships under Apache-2.0 can accept an MIT-or-Apache-2.0 dependency by taking the Apache branch. A team that string-matches will either block it wrongly or wave through Apache-2.0 AND GPL-2.0-only, which is a combination the FSF treats as incompatible in that direction.

Codequiry API keys page with a masked key, signed webhook configuration and API resources
The API surface: an account key, signed webhooks for finished checks, and docs for wiring scans into CI.

The header job runs less often, usually nightly or on merge to main. REUSE is the fastest route: reuse lint exits nonzero for any file lacking an SPDX identifier, and it does not care whether the file is source, a fixture, or a vendored copy of someone else's library. For repositories with an established convention and no interest in retrofitting, ScanCode's --license --copyright output can be diffed against a checked-in baseline so that new unlicensed files fail and legacy ones are tolerated until a deadline.

Where the gate gives wrong answers

License detection is a classification problem over noisy text, and the failure modes are consistent enough to plan for. Packages that declare SEE LICENSE IN LICENSE.txt are unparseable by anything short of reading the file, and roughly 60 percent of the entries that landed in the Denver team's UNKNOWN bucket were internal workspace packages that had simply never been given a license field. The gate shipped in October rather than July because of exactly that: 44 of 61 UNKNOWN packages were first-party, and the whole policy had to distinguish "we own this" from "we do not know".

The JSON license is the canonical edge case. It is SPDX-listed, it is not OSI-approved, and its "The Software shall be used for Good, not Evil" clause has kept it out of Apache projects for fifteen years. Whether the gate permits it is a legal question, not a technical one, and the answer changes depending on whether the code is a build tool or shipped in a binary.

Vendored code is the other reliable source of false results. A vendor/ or third_party/ directory holding a checked-in copy of a library appears in no manifest, so the dependency job never sees it. Header scanning catches it only if the original headers survived the copy, and stripping them is common practice among developers who find eight lines of boilerplate visually annoying.

The blind spot no SBOM will catch

None of the jobs above touch the code a developer writes by hand, and that code is where the licensing exposure actually lives. The GPL-3.0 hook pulled from a GitHub gist, the 40-line CSS reset from a CodePen with no stated license, the debounce utility copied from a Stack Overflow answer that was itself copied from a GPL project: none of these appear in package.json, none appear in the SBOM, and none carry an SPDX header unless someone put one there.

Provenance scanning is a separate step, and it is the one due-diligence teams ask for during an acquisition. A source code plagiarism checker built for this purpose runs the repository against public GitHub repositories and web sources, then reports which files match, at what similarity, and against which upstream license. Engineers who have run the exercise describe the results as unsurprising in aggregate and alarming in specific: a handful of files with clear public provenance, most of it permissive, one or two that are not.

Codequiry web results tracing copied code to GitHub repositories and other web sources with per-domain scores
Web results: every domain a submission matched, scored per source, from GitHub repos to tutorial sites.

The mechanics matter here. Surface text matching produces noise on code, because indentation, variable names, and formatting change constantly. Token-level and AST-level comparison, which is what Codequiry uses underneath its code plagiarism checker, survives renaming and reformatting well enough to trace a refactored function back to its origin, which is what a provenance audit needs. The same scan reports AI-generated code, a category with its own licensing question: model output has no attributable license at all, which makes it the one dependency no allowlist can classify.

Codequiry evidence review with a synced diff of two Java files and a list of GitHub and web matches
Evidence review: a synced diff of the matched lines next to every peer, GitHub and web source for the submission.

Raghunathan's team runs the provenance pass quarterly rather than per-pull-request, on the grounds that per-commit scanning of a monorepo generates more review work than any legal team will absorb. That cadence is a judgment call, not a best practice, and she is explicit about it.

Who owns the allowlist

The technical implementation is the easy half. The policy file is where the argument happens. Engineering wants a broad allowlist so the gate does not obstruct work. Legal wants a narrow one so that nobody has to reconstruct a decision two years later. The compromise most organizations land on is a small set of unconditional approvals, a conditional tier that requires a named exception with an expiration date, and a documented escalation path.

"An exception with no owner and no date is not an exception, it is a permanent policy change that nobody voted on," said Marcus Oyelaran, associate general counsel at a software company in Chicago. "We ask for a name, a reason, and a review date. The review date does the work."

Two operational details are worth writing down before the gate goes live. Exceptions need to live in the repository, in a file the workflow reads, so that the policy is versioned alongside the code it governs. And the failing check needs a message that tells the developer what to do next, because a bare red X on a pull request from someone who added a dependency in good faith generates a Slack thread, not compliance.

Frequently Asked Questions

Does a dependency scanner catch code copied from Stack Overflow?

No. Dependency scanners walk manifests and lockfiles. Code pasted directly into a source file has no manifest entry, so it is invisible to the scanner regardless of which tool is used. Catching it requires a provenance scan that compares repository contents against public sources.

Is GPL-2.0 compatible with Apache-2.0?

Not in the direction most teams need. Apache-2.0 code cannot be combined into a GPL-2.0-only work because of the patent and indemnification terms, which is why the FSF lists the combination as incompatible. GPL-3.0 does accept Apache-2.0 code. This is exactly the case a string-matching gate gets wrong and an SPDX expression parser gets right.

How often should the license gate run?

The dependency job belongs on every pull request. Header linting is reasonable nightly or on merge. Provenance scanning is typically quarterly or before a funding round or acquisition, because the review volume is high and the underlying sources change slowly enough that continuous scanning adds little.

What happens when a package has no declared license?

Treat it as all rights reserved. No license means no grant of rights, regardless of whether the code is on a public registry. The gate should fail closed on unknown identifiers rather than defaulting to a permissive assumption, with a first-party allowlist so internal packages do not generate noise.

The workflow above is roughly 90 lines of YAML and Python. The policy behind it takes longer to negotiate than to implement, and the provenance gap it leaves open is the one worth closing deliberately. Running a code plagiarism and provenance scan against the open web and GitHub before a release or a diligence request is the difference between a compliance program that describes your dependencies and one that describes your codebase.