Licensing gates
A repository that declares its licensing the REUSE way gets three checks from Praetor:
reuse lintbefore every commit and in CI, written bypraetorctl adoptwhere the root carriesREUSE.tomlor.reuse/dep5and the manifest does not declinereuse-gate(aLICENSES/directory alone declares nothing, #1244);- the annotation order check of
praetorctl audit, which fails an override inREUSE.tomlthat never takes effect; - the root licence check of
praetorctl audit, which holds the root to oneLICENSEwith the declared licence's text.
The annotation order check reads REUSE.toml, and the root licence check runs where LICENSES/
is a directory. A repository with none of these gets none of the checks, and the audit says that
it skipped each. One whose LICENSES/ and file headers label it the REUSE way without
REUSE.toml or .reuse/dep5 gets an audit warning that names REUSE.toml, since adoption
renders no REUSE gate there. No profile writes a LICENSE or LICENSES/ for the repository,
so the checks follow the files, whatever the profile.
reuse lint in hooks and CI
A newer push to a pull request cancels its older reuse.yml run, and the workflow's checkout drops the job token after cloning (concurrency and checkout credentials).
The reuse.yml workflow also triggers on merge_group, so its check reports for a merge group when the repository declares a merge queue (Merge queue). An unedited copy of the earlier text without the trigger refreshes without --force for a repository whose default branch is main; for another default branch it needs --force.
The rendered .config/reuse/requirements.txt carries an SPDX copyright and
EUPL-1.2 licence header so the REUSE gate can audit the lock itself; a lock
without licensing information fails reuse lint. Adoption recognizes the earlier
headerless rendering and refreshes an unedited copy without --force, while
preserving local edits (internal/adopt/reuse_requirements.go,
reuseRequirementsDigests in internal/adopt/reuse_gate.go, and
TestAdoptReuseRequirementsRefreshesHeaderlessRendering in
internal/adopt/reuse_gate_test.go).
Adoption writes two jobs:
-
.github/workflows/reuse.yml, whose REUSE lint job installs reuse from the hash-pinned lock.config/reuse/requirements.txt(compiled from.config/reuse/requirements.in, reuse 6.2.0, kept current by the same Renovate pip-compile rule as the hook-lint lock) withpip install --require-hashesinto a virtual environment and runsreuse lint, withintimeout-minutes: 10. No container action runs and no image is pulled from Docker Hub, whose pull failures once failed an adopter's required check (#1128);TestEmittedWorkflows_RefuseContainerActionsrefuses a container action in any emitted workflow, andTestReuseRequirementsLockMatchesCommittedkeeps the emitted lock equal to the engine's. The job becomes a required status check of the branch ruleset adoption renders.actions/checkoutis pinned by full commit SHA with a version comment (the checkout pin fromtools/markdownlint/assets.go, HISS-11). Where another workflow of the repository already runs a REUSE lint job (a job namedreuseorREUSE lint, a step runningreuse lint, orfsfe/reuse-action), adoption writes noreuse.yml, so no required check runs twice (#1327,TestAdopt_AdopterWithExistingReuseJob_NoSecondJobEmitted). The previous rendering, which ranfsfe/reuse-action, is recorded, so an adopter that holds it unedited is refreshed on a plain adoption (TestAdopt_RefreshesMainsPriorDigestPinnedReuseWorkflow). It has the hosted gate shape every emitted gate shares (internal/ghworkflow/hostedgate.go): it runs on pull request activity and on a push to the default branch that ruleset protects, its first step fails a draft run by design (onbash, so it behaves the same on every runner), and every later step runs only when the pull request is not a draft. The branch isrepository.default_branchof.standards.yaml, else the origin HEAD the checkout records, elsemain(forge.RepositoryDefaultBranch), written asbranches: ['master']. For a repository owned by thepluranoorganisation, adoption renders.github/workflows/reuse.ymlas a reusable workflow caller invokingplurano/horistes/.github/workflows/licence.ymlpinned by full 40-hex commit SHA rather than copying the steps and requirements lock into the repository. The byte-lock audit accepts any 40-hex SHA pin (enabling Renovate'sgithub-actionsmanager to update it without failing drift verification) and refuses unpinned refs or tags (TestAdopt_PluranoRendersLicenceCaller,TestAdopt_PluranoAcceptsBumpedSHA,TestAdopt_PluranoRefusesNonSHACallerininternal/adopt/reuse_gate_test.go). A plurano repository holding an unedited copy of the older standalone workflow is refreshed to the caller on adoption (TestAdopt_PluranoRefreshesCopiedReuseWorkflow); -
a
reuse-lintpre-commit job inlefthook.ymlthat runsreuse lintwhere reuse 6.x is installed, skips with the reason where reuse is not installed, and fails, naming the pin, on another reuse major.
For a plurano-owned repository with .github/workflows/reuse.yml, praetorctl audit requires its
sole job to call plurano/horistes/.github/workflows/licence.yml at a full commit SHA. It reports
a copied gate or a tag, branch, or short SHA pin with praetorctl adopt as the repair.
Praetor's own Compliance workflow installs the same lock the same way. The reuse-lint hook job
names the reuse release line by supplychain.ReuseActionVersion, and praetorctl bump expects
the same tag (TestReuseActionPinMatchesRegistryAndEngine in internal/bump/release_pins_test.go).
The workflow follows the root on every adoption. Praetor's unedited rendering, for the current
default branch or another one, is refreshed to the current default branch without --force.
When the root loses both REUSE.toml and LICENSES/, adoption removes the unedited workflow,
so the ruleset it renders next stops requiring a check that reuse lint would fail on every
pull request, and drops the reuse-lint job from an unedited lefthook.yml. An edited
reuse.yml is kept, --force included, and adoption warns that its check now fails
(TestAdopt_ReuseGateFollowsTheDefaultBranch and TestAdopt_ReuseGateRemovedWithTheMarkers in
internal/adopt/reuse_gate_test.go).
Fast adoption lists how adoption
treats an existing workflow and how to decline it; git hooks lists the other
pre-commit jobs.
Annotation order
REUSE 3.3 applies to a file only the last [[annotations]] table of a REUSE.toml whose
path matches it, "exclusively the last matching table in the file", whatever the table's
precedence. precedence = "override" and "aggregate" only decide between a table and the
licensing information inside the file or in another REUSE.toml. An override placed before a
whole-tree default table is therefore relabelled by the default, and reuse lint still passes,
because every file still carries licensing information.
praetorctl audit fails when the paths of later annotations together match every file a path
of an annotation names, and names the later annotation that completes the match:
- REUSE.toml annotation 1 path "vendor/upstream/**" never takes effect: annotation 2 path "**", after it, matches every file it names, and REUSE applies only the last matching annotation, whatever its precedence; move annotation 1 after annotation 2
[FAIL] REUSE.toml annotation order: 1 path(s) resolve to a later annotation, not their own
A default spelled as several globs, such as path = ["*", ".*", "*/**", ".*/**"], is listed
with all of them. When no one later annotation matches every file but several do between them,
the finding names the range, such as annotations 2 to 4, after it, together match every file it
names, and asks to move the annotation after the last of them.
Put the default table first and every override after it. Later paths that match only some of an
annotation's files, such as **/*.md after docs/**, leave the rest in effect and are not
reported. Paths compare as REUSE globs: * stops at /, ** crosses it, and \ makes the
next character literal (ReuseShadowedPaths in
internal/supplychain/reuse.go
and reuseGlobCheck.includes in reuse_glob.go, the one coverage decision every licensing check
uses). Tests: TestReuseGlobIncludes_3D and TestReuseShadowedPaths_*, _Union among them,
in internal/supplychain/reuse_shadow_test.go, and TestAuditReuseRecords_3D in
cmd/standardsctl/audit_reuse_test.go.
What the audit reads
REUSE.toml is parsed as TOML by github.com/pelletier/go-toml/v2 (util.DecodeTOML), so
malformed TOML, a duplicate key included, is refused with the file named instead of being read
line by line, and a multi-line path array or an inline annotation table reads as the reuse tool
reads it (TestDecodeTOMLNegative in
internal/util/toml_test.go).
Both audit checks read REUSE.toml against an allow-list, following what the reuse tool parses
(ReuseTOML.from_dict and AnnotationsItem.from_dict in reuse/global_licensing.py), stepping over
metadata keys and tables while refusing shapes that could define untracked annotations
(TestReuseOtherKeysAndTablesPositive in
internal/supplychain/reuse_test.go
and TestAuditLicensing_Issue896 in
cmd/standardsctl/audit_reuse_test.go):
- every top-level key,
versionincluded, is stepped over whatever its value holds, exceptannotationsand dotted keys starting with it, which are refused; [[annotations]]opens an annotation table, while every other table or array-of-tables header opens an ignored table whose keys,annotations = ...andannotations.xamong them, are all stepped over and never reach the annotation table before it;- inside each
[[annotations]]table,pathandSPDX-License-Identifierare read (each a single-line string or an array of them), whileprecedence,SPDX-FileCopyrightTextand other keys are stepped over whatever they hold (util.TOMLValueScanininternal/util/toml.go).
Header and key lines are tokenized by one function, util.TOMLKeyPath, following the key grammar of
TOML 1.0 (toml.io/en/v1.0.0). A key is one or more segments joined by dots; a segment is a bare key
(A-Z a-z 0-9 _ -), a basic string without any backslash, or a literal string. Spaces and tabs are
allowed between tokens only, never inside a bare key or a quoted one. A header is [ key ] or
[[ key ]] with optional spaces and tabs inside the brackets, then nothing or a comment. The
decoded first segment is compared exactly: [[annotations\t]], [[ annotations ]] and
[["annotations"]] open an annotation table, as REUSE reads them, while [["annota tions"]] and
[[" annotations"]] name other keys and open ignored tables, as REUSE ignores them
(TestReuseHeaderTokenizerPositive, confirmed with reuse lint where the tool is installed and
skipped, stating why, where it is not).
Refused shapes fail both checks with the line and the allowed shape: a header or key line that does
not tokenize (an unclosed bracket or quote, text after a header, a bare key character outside the
set above, a backslash in a quoted key, whitespace other than space and tab); annotations and
annotations.* at top level; [annotations], [annotations.x], ["annotations".x] and
[[annotations.x]]; and dotted path, precedence, SPDX-FileCopyrightText or
SPDX-License-Identifier keys inside an annotation table (TestReuseHeaderTokenizerNegative and
TestReuseHeaderTokenizerBoundary in
internal/supplychain/reuse_test.go,
TestTOMLKeyPathNegative in internal/util/toml_test.go).
Annotations written as an inline array of tables, which REUSE accepts, fail this way:
[FAIL] REUSE.toml annotation order not checked: REUSE.toml:2: the top-level key annotations is not one this read follows: write each annotation as a table of its own, opened by a [[annotations]] line and holding one key = value per line, not as an inline array of tables or dotted keys
Rewrite each element as an [[annotations]] table. Dotted keys, such as annotations.path, fail
the same way. A basic string's escape sequences are decoded as TOML defines them, so
path = "a\\*", the escaped star of the REUSE specification, reads as the glob a\*, as the
literal string path = 'a\*' does. A multi-line string as a path or SPDX-License-Identifier,
an escape sequence TOML does not define, and a value whose end the read cannot find fail both
checks rather than passing them unchecked (TestReuseAnnotationTablesPositive and
TestReuseAnnotationTablesNegative in internal/supplychain/reuse_test.go, and
TestAuditLicensing_3D).
The comparison bound
Comparing globs explores the product of their automata. Every check spends at most
16,777,216 automaton steps (maxReuseGlobSteps in reuse_glob.go), one step for each byte one
glob reads, so the bound measures the real cost, not the number of tables: a file listing hundreds
of vendored files, one table each, stays far below it (TestReuseShadowedPaths_Boundary). A file
whose comparisons need more is not answered in part: the check fails as not checked, naming the
annotation, the path and the bound (TestReuseGlobCheck_Budget,
TestReuseShadowedPaths_PastTheBound). Merge its globs into fewer tables, or, while you do, keep
the file unchecked with an exceptions entry in .standards.yaml:
exceptions:
- rule: "reuse-annotation-order"
path: "REUSE.toml"
reason: "4000 generated vendored file globs; merging them tracked in the backlog"
expires: "2026-12-31"
The audit then prints the order as not checked, with the entry's reason and expiry, and never as a
pass. An expired entry excuses nothing, and an entry for a file the audit checks in full is stale
and fails the check until it is removed (TestAuditReuseRecords_Bound).
One root licence
Forges and package indexes read the licence from the files at the repository root, and the REUSE
specification exempts COPYING, LICENSE and LICENCE, with any - or . suffix, from
labelling. GitHub's licence detection, Licensee, reads more names as licence files
(FILENAME_REGEXES in lib/licensee/project_files/license_file.rb). A repository that keeps LICENSES/ therefore states one licence at its root, and
praetorctl audit checks it (CheckRootLicense in
internal/supplychain/root_license.go):
- The declared licence is the one SPDX identifier of the last
REUSE.tomlannotation whose paths cover the whole tree, such aspath = "**"orpath = ["*", ".*", "*/**", ".*/**"]; without such an annotation, the one textLICENSES/holds. A whole-tree annotation naming an expression, such asMIT OR Apache-2.0, or aLICENSES/with several texts and no whole-tree annotation, declares no single licence, and the check skips, saying so. LICENSEmust be a regular file holding the text ofLICENSES/<id>.txtfor that licence. The comparison allows one consistent line-ending style, as every other text comparison of the audit does, so a CRLF checkout passes; a text with mixed line endings is compared byte for byte, and the finding says so. ALICENSEthat is a symbolic link, such as one toLICENSES/<id>.txt, or a directory is a finding that asks for a regular copy: an archive or a forge may not follow the link.- Every other root file named like a licence fails the check unless the manifest keeps it as
an upstream notice. Names compare without case, and a name counts when REUSE or Licensee reads
it as a licence (
licenceNameForms):COPYING,LICENSE.md,LICENCE,LICENSE-MIT,LICENSE_MIT,UNLICENSE,MIT-LICENSE,COPYING_x,COPYRIGHT,OFLandPATENTS, with the extensions Licensee accepts after them. Directories are not read. A notice under another name, such asNOTICE, is not a licence file, and neither iscopyright.go, whose extension Licensee refuses (TestLicenceNamed_3D).
To keep an upstream notice, name it in the top-level exceptions list of .standards.yaml, one
file per entry, by path and at the root, with the reason and an expiry within the category interval (A: stated, at most 90 days; B: 3 days, C: 10 days, D: 120 days; uncategorized: 90 days),
as for the clang-tidy coverage gate:
exceptions:
- rule: "root-license-notice"
path: "COPYING"
reason: "upstream GPL notice the fork keeps verbatim"
expires: "2026-12-31"
An expired entry keeps nothing, and an entry that keeps no root licence file is stale and fails until it is removed:
- COPYING: a second root licence file; its root-license-notice exception expired on 2026-10-06
- exceptions entry LICENSE-MIT (root-license-notice): keeps no root file named like a licence; remove the entry
[FAIL] root licence: 2 problem(s) with the one root licence of a repository declaring EUPL-1.2
Tests: TestCheckRootLicensePositive, TestCheckRootLicenseNegative and
TestCheckRootLicenseBoundary in internal/supplychain/root_license_test.go,
TestValidateExceptionsRootLicenseNotice in internal/config/exceptions_test.go, and
TestAuditLicensing_3D in cmd/standardsctl/audit_reuse_test.go.