Repository navigation
Checkout audit
Six targets on the checkout itself, not on what it ships. make consistency-audit holds the statements the metadata makes against each other; make build-verify asks whether the build output committed to the repository comes from the source beside it; make changelog-lint asks whether the App Store finds this release in the changelog; make php-compat asks whether the app runs on the PHP versions its servers run it on; make deps-audit asks whether a dependency that ships has a published vulnerability, or a licence the app cannot ship it under; make secrets-audit asks whether a key or a token is in what ships, or in the history.
Tip
TL;DR — make dev-init once per machine, then make consistency-audit (a second, metadata only), make build-verify (minutes: it installs and builds the checkout twice, outside your app), make changelog-lint (a second; make tag runs it before its confirmation), make php-compat (after make build; it runs one build image per PHP version it checks, pulled on first use) make deps-audit (seconds; it asks the package managers' audits, so it needs the network) and make secrets-audit (seconds; make tag and make dist run it on what ships). Each prints a verdict with findings; ARGS="--details" adds the tables where a target has them, ARGS=--json the machine-readable form, make help-<target> the full option list.
- What consistency-audit checks
- What build-verify does
- What changelog-lint checks
- What php-compat checks
- What deps-audit checks
- The licences of the dependencies
- The licence table
- What secrets-audit checks
- Options
- Where the analysers live
- FAQ
An app states the same fact in several files, and each file is valid on its own. A linter checks one file against a rule and cannot see any of that; the audit holds the files against each other and reports where they disagree:
- the version in
appinfo/info.xmlagainst the one inpackage.json - the PHP floor in
info.xmlagainstcomposer.json. A lower floor ininfo.xmlis a[!]finding: the server enables the app on a PHP below the composer floor, where the packages resolved for that floor may not run. A lower floor incomposer.jsonis a note that names the version to raise it to, since the server does not enable the app belowmin-versionandmake buildresolvesvendor/on that floor or above - every PHP extension that
composer.jsonor a package incomposer.lockrequires against the<lib>entries ininfo.xml, unless every Nextcloud server has it - the Node version in
package.jsonagainst.nvmrcand against the installed workflows
The second half is what a release needs and a checkout can be missing:
- the
<repository>and<bugs>links, the only way from a published release back to the project - a
<nextcloud>range that names servers that exist and reaches no further than the App Store publishing rules allow at the time of the release: the newest released major plus one - an app name in
<name>without Nextcloud in it, which those rules demand as well - an
engines.nodethat admits a Node line the release can be built on. ncmake picks the build Node from the Node.js release schedule, and the audit picks it the same way. A range that admits only end-of-life lines is a[!]finding that names the line the release is built on, the date it reached end of life and the oldest line still maintained. A range that admits no line released so far is one as well, with the first line it admits and its start date, since make then falls back to the newest LTS line, which the range does not admit. So is a range that admits no LTS line, with the date the first line it admits enters LTS: a contributor on the current LTS cannot build the app - a lockfile, and one rather than two
- a licence stated the same way everywhere
- a linter that a script calls while no configuration for it is in the tree
- a directory the code reads at run time that the shipped file set leaves out: the PHP that ships reaches it through
__DIR__, or the frontend throughlinkToorgenerateFilePathwith the app id. The build and the packing pass without it, and the app fails later, in the one code path that reads it. The report names the first place that reads each such directory and thekeep_extraline inncmake.mkthat ships it. In an ncmake app it is a[!]finding; in any other app it is a note on what adopting ncmake would need, since such an app packs its own file set
The audit asks whether each link of the App Store listing in info.xml answers: <website>, <bugs>, <repository>, the <user>, <admin> and <developer> links of <documentation>, every <screenshot> with its small-thumbnail, and <donation>. A moved wiki, a renamed repository or a screenshot on a deleted branch leaves a listing with dead links, and nothing else tells the maintainer. It sends one HEAD per link, a GET where a server refuses HEAD, follows redirects itself, asks each URL once per run and waits at most 10 seconds:
| id | level | when |
|---|---|---|
link-dead |
[!] |
the link answers 404 or 410, its host does not resolve, or it is no http(s) URL |
screenshot-not-image |
[!] |
a <screenshot> is not served as image/*, such as the page of a github.com/.../blob/... URL instead of the file on raw.githubusercontent.com
|
link-error |
[i] |
another 4xx or a 5xx, a timeout, a refused connection or a redirect loop: it may be temporary, or a server that refuses requests without a browser |
link-redirect |
[i] |
a permanent redirect (301 or 308), with the address to write instead. A move that only drops .git or a trailing slash is the same address and not named, since <repository> states the git URL |
link-insecure |
[i] |
an http:// link the server does not move to https, while the same address answers over https://
|
link-pending |
[i] |
a screenshot pinned to a release tag of the app's own repository, which make version moves along (App Store screenshots), whose tag does not exist yet and whose file is in the working tree; it answers once the tag is pushed. Without the file it is link-dead
|
link-unchecked |
[i] |
the run reads no network, NO_NETWORK=1, or not one link answered at all |
A tag the checkout lacks but origin has, as in a shallow clone, answers and is checked like any other link. NO_NETWORK=1 leaves the links unchecked, and the report says so.
It also names the build file make reads: of GNUmakefile, makefile and Makefile, only the first one present. It warns when that file hides an ncmake stub or copy, so that no ncmake target exists, and points a stub still committed as Makefile or makefile to git mv <name> GNUmakefile.
Each of those is reported once, with the reason it matters, and never as a score. What the checkout does not state is named as not stated instead of being guessed at. The one exception is the oldest Nextcloud major still maintained, which is written into the analyser, and the report names the month that is from. The values the audit takes from the network are read when it runs: the newest released major, the highest major among the release tags vX.Y.Z of nextcloud/server (a beta or rc tag is no release), the major nextcloud/server develops on master, from its version.php, and, for an app with a package.json, the Node.js release schedule. A <nextcloud max-version> beyond master names servers nobody has run the app on; one beyond the newest release plus one breaks the publishing rules, which happens while the next major is branched and not yet released. Either is a [!] finding. Without the network a list of majors and a snapshot of the schedule in the analyser stand in, and the report says where each value came from and on which day the schedule was judged.
It reads metadata only: no build, no install, no dependency tree, no bytes, and from the network besides the values above only the links. That makes it useful on a fresh clone, and on an app that never heard of ncmake.
It copies the tracked files into a scratch tree outside the app, installs the locked dependencies there, runs the build — twice — and compares what came out against what the checkout carries.
What it compares is where the build writes. Where the bundler config names that directory, output.path in a webpack or rspack config or build.outDir in a vite config, that directory counts, together with js/ or css/ only where git ignores them. A committed js/ next to a config that writes to dist/ is then the app's source. The config is read as text, not run, so a path counts only where the config writes it out literally, as a string or as path.resolve(__dirname, 'dist'). A computed one, or a config that leaves the output to a shared base config such as @nextcloud/webpack-vue-config, falls back on js/ and css/. ARGS=--build=<dir> overrides both.
Source files can sit in an output directory as well, next to what the build writes there. They are told apart by what refers to them: a relative import, a name the bundler config maps to a directory through resolve.alias, a require.context in webpack, an import.meta.glob in vite, or a name the app loads the file by in its PHP code, its templates or info.xml.
Twice, because a single build cannot tell the two answers apart. A file that differs after one build was either built from another source or is written differently every time, and only a second build separates them: what both builds wrote byte for byte the same way is what this source produces, so a difference against the checkout is the checkout's. A file the two builds disagree on is named and gets no verdict, because no build reproduces it. The second build runs in another directory, so output that depends on where the build ran counts as not reproducible too. ARGS=--once builds once and states the open question instead of answering it.
A difference both builds reproduce means the output in the checkout was built from another source, and make build is the remedy. Where git ignores the output and make build has just run on this working tree, that cannot be the reason: ARGS=--fresh says so, and the same difference then reads as a build that depends on something a fresh clone does not have, or on the directory it ran in. That output is a build of this source in another directory as well: where it matches the first build on every file name and every byte but build metadata, it stands in for the second build, which then does not run.
Hashed file names are paired by content, so app-3f9c1a2b.mjs and app-8b20de41.mjs are read as one file under two names, and a reference that carries the hash counts as build metadata rather than a difference. A source map is compared as the map it is: what it was compiled from decides, the directory it records its sources under does not.
An initialized submodule is source like the app's own, so its tracked files are copied too. One the checkout has not initialized cannot be, and when the build then fails, the report names the submodule and says the build could not be verified, rather than blame the app for source the rebuild never had. git submodule update --init fetches it.
The scratch tree is a git repository with the HEAD and the tags of the checkout, and git lists exactly the files copied into it, so a build that runs git ls-files or git describe gets the answers a fresh clone gives. The build output stays out of the tree, so git describe --dirty reports it as dirty.
In a container the analyser runs on lib_node_image with the newest LTS line like every other one, so an engines.node that admits only Node 14 or 16 cannot keep it from loading. The build steps run on the Node line the app declares all the same: the directory of that line from nodejs.org, the one make build runs on, is mounted read-only beside it, and its bin goes first on PATH for the build steps alone. For a node_image of your own, its /usr/local, where the Node images keep node, npm and corepack, is copied out of a container that is created from that image and never started, and mounted in its place. An app on an end-of-life Node line is rebuilt like any other, and the runtime line of the report names the Node the build ran on.
The checkout is read and never written to — the build output in it stays where it is, nothing is installed into it, and the scratch tree is removed again.
Important
This is the one analyser that installs and builds, so it needs the network and takes minutes rather than a second. Where it runs it needs git and npm: in a container lib_node_image brings git and the mounted Node line the npm, with RUNTIME=bare the host brings both.
The App Store takes the changelog of a release from the tarball and nowhere else. It reads exactly two kinds of file there:
| File | Read as |
|---|---|
CHANGELOG.md |
English |
CHANGELOG.<code>.md |
one per language of the store's list, such as CHANGELOG.de.md or CHANGELOG.pt-br.md
|
Each is decoded as UTF-8, and what the store shows is the section under the heading that names the version in appinfo/info.xml: a line that begins with ## 1.2.3, ## [1.2.3] or ## v1.2.3. A version with a - is a pre-release and is read from ## [Unreleased], as a nightly is. Whatever the store does not find, the release is published without, and nothing reports it.
A tagged pre-release is a release of its own all the same, and changelog-lint holds it to the section that names it, ## [1.2.3-rc.1], as make changelog writes it. The store shows that section on the page of the stable 1.2.3, below that release's own section. On the page of the pre-release itself it shows ## [Unreleased]; the lint reports that with [i] and does not demand it.
changelog-lint repeats that reading on the checkout, with the rules of the App Store's release parser:
-
[!]noCHANGELOG.md -
[!]a name that differs only in its spelling (changelog.md,CHANGELOG.MD,CHANGELOG.De.md,CHANGELOG.pt_BR.md), together with thegit mvthat fixes it -
[!]a language code the store does not know;en-auanden-gbare left out of its list -
[!]a file that is not valid UTF-8 -
[!]a changelog without a section for this version, or with an empty one; a byte order mark that hides the heading on the first line is named as the reason -
[i]a pre-release, whose own page on the store shows the Unreleased section instead of its section -
[i]a changelog under a name the store never reads (CHANGES.md,HISTORY.md,CHANGELOG) -
[i]aCHANGELOG.en.md, which the store reads as English afterCHANGELOG.mdand in its place -
[ok]the number of lines the store shows, per language
The store's language list is written into the analyser, as the Nextcloud majors are into consistency-audit, and the report names the Django release it is taken from. ncmake ships CHANGELOG.md and every CHANGELOG.*.md, each with its .license sidecar (see Building and packaging). make changelog writes the English section; a translation is written by hand.
Unlike consistency-audit and build-verify, changelog-lint exits 1 on a [!] finding. make tag runs it before its confirmation and refuses on that exit code; make tag NO_CHANGELOG_LINT=1 tags regardless (see Releasing). Elsewhere the exit code stops a chain of commands, or fails a CI job, which holds a merge back only where branch protection makes it a required check.
A Nextcloud server states the PHP versions it runs on in its lib/versioncheck.php, and the app states the servers it runs on in appinfo/info.xml. Between the two lies every PHP the app meets in the field. make build resolves the dependencies on the lowest of them, so a package that requires a PHP below the highest one installs without complaint, and a file that does not parse on another PHP does not show either, until a server runs it. php-compat checks two of them:
| PHP | What it is |
|---|---|
| the floor | the PHP make build resolves against: <php min-version>, raised to the floor of the newest end-of-life Nextcloud major (see How ncmake understands your app) |
| the ceiling | the newest PHP minor that the server of <nextcloud max-version> runs on, read from its stable branch, or from master for a major without one; a higher <php max-version> raises it, but no further than the ceiling of master |
PHP=all checks every minor in between as well.
A <php max-version> above the ceiling of master runs once more, apart from these, on the PHP ahead of master: in ghcr.io/ernolf/ncmake-php:<minor>-next. NEXT=1 runs ghcr.io/ernolf/ncmake-php:next, the newest of those minors, for an app that does not declare it.
Each runs in the build image of that minor, ghcr.io/ernolf/ncmake-php:<minor>, which carries the extensions a Nextcloud server offers, and does two things there:
-
composer check-platform-reqs --no-dev: whether every installed package, and the app's owncomposer.json, accepts this PHP -
php -loverappinfo/,lib/,templates/andvendor/: whether every PHP file that ships parses on this PHP, and what this PHP reports as deprecated in it. The lint loads no extension, since a parse calls none, runs as many PHP processes as the runtime has processors and, from PHP 8.3 on, lints 64 files per process
The findings:
-
[!]a<php max-version>below the ceiling: a server on a newer PHP refuses to enable the app -
[!]a<php max-version>above the ceiling of master: no server runs that PHP yet, so nobody has checked the app on it, and once a server allows it, it enables the app there. Like a<nextcloud max-version>above master inconsistency-audit, the declaration is the finding, not a run that could not be checked -
[!]a package, or the app, that requires a PHP the check ran on does not meet -
[!]a shipped PHP file that does not parse -
[!]a PHP version that could not be checked, an image that could not be pulled for instance -
[i]a deprecation or warning while parsing, grouped by package;--detailslists every file -
[i]an extension the build image lacks, which depends on how a server builds its PHP rather than on the app -
[i]a file of a dev package invendor/, whichmake builddoes not ship -
[i]novendor/and nocomposer.lock, so composer had nothing to check -
[ok]the PHP versions checked, and how many files parse on them
The run ahead of master prepares the app; it does not show that a server runs it. Its findings carry a php-next- id and are named after the exact PHP, for example 8.6.0RC3, and a [!] among them is reported as [i], so they never change the exit code. A php-next finding states whether that PHP has a release yet, and php-max-above-master says the same, or that the -next image could not be run. A -next image whose PHP master already allows is reported as php-next-none and left out.
It reads what is installed, so run it after make build: then vendor/ holds what ships. Without vendor/, composer reads composer.lock, and only the files of the app are linted. The output of each run stays in build/php-compat/<minor>, that of the run ahead of master in build/php-compat/next. It exits 1 on a [!] finding, so a CI job that runs it after make build fails on it. With RUNTIME=bare it runs on the PHP of the host, once per minor, and says that it did; the run ahead of master needs its image, so it is left out, and the output says so.
A release ships the runtime packages of composer.lock in vendor/ and the production packages of the npm lock file bundled into js/. An advisory published for one of them concerns the release, whether or not the app's own code is at fault. deps-audit asks the package managers' own audits about exactly those packages, with dev dependencies, build tools and test frameworks left out:
| Lock file | Audit | Where it runs |
|---|---|---|
composer.lock |
composer audit --locked --no-dev |
the build image of the PHP floor, ghcr.io/ernolf/ncmake-php:<floor>
|
package-lock.json, npm-shrinkwrap.json
|
npm audit --omit=dev |
the Node line the app declares |
pnpm-lock.yaml |
pnpm audit --prod |
the same, pnpm through corepack |
yarn.lock of Yarn 2 and later |
yarn npm audit --environment production --recursive |
the same, yarn through corepack; without --recursive it reports the direct dependencies only |
yarn.lock of Yarn 1 |
yarn audit --groups dependencies |
the same |
Each vulnerable package is one finding with its advisories, the highest severity among them and the first released version above the locked one that fixes all of them. Whether the declared constraints let an update reach that version decides which:
-
[!]vulnerable-dependency: the constraints allow the fixed version, and the update command the finding names is enough,make composer ARGS="update <package> --with-dependencies"ormake npm ARGS="update <package>" -
[!]vulnerable-dependency-pinned: a constraint excludes it. The finding names the constraint to widen and who declares it:composer.json,package.jsonor the package further up that depends on it -
[i]abandoned-dependency: composer reports the package as abandoned, with the replacement its maintainers suggest -
[i]audit-unavailable: the advisory source did not answer. The report says that nothing of that lock file was checked, rather than report no finding -
[i]lock-missing:composer.jsonorpackage.jsonhas dependencies, but there is no lock file to audit -
[ok]no shipped package has a published advisory
composer decides the constraint question itself, with composer why-not. For npm the ranges come from package-lock.json, which records each dependent with the range it declares, or from yarn.lock, which records the ranges; pnpm-lock.yaml records only those of package.json, so for a package further down a pnpm finding says that it could not tell. composer audit reads the audit.ignore of composer.json, so an advisory the app has assessed and accepted stays out.
Both vulnerability findings carry "security": true in the JSON, with the severity, the fixed version and every advisory. The report is written to build/reports/deps-audit.txt and build/reports/deps-audit.json, the raw output of the audits stays in build/deps-audit, and the exit code is 0 with or without findings: the gate is the lint-deps workflow of ncmake-workflows, with a severity threshold of its own and a schedule, because an advisory published tomorrow concerns today's release. It reads the lock files, not vendor/ or node_modules/, so it runs on a fresh clone. It needs the network for the advisories and the released versions; with RUNTIME=bare composer, npm and corepack run on the host.
NO_ADVISORIES=1 (or ARGS=--no-advisories) leaves the vulnerabilities out, for a run whose output is public. No npm audit runs, composer audit keeps only the abandoned packages, and build/deps-audit, the report and the JSON say nothing about an advisory. The [ok] above goes as well, since its absence would name the vulnerable apps; every app gets the same [i] advisories-unchecked in its place, and the JSON has "advisories": false. The licences and the abandoned packages are checked as without it.
A bundle is a combined work. A copyleft package in it puts its conditions on the whole app, and a package without a licence cannot be distributed at all. make reuse covers the app's own files; deps-audit covers what the app takes from others: the same shipped packages, held against the licence appinfo/info.xml declares.
| Lock file | Where the licence comes from |
|---|---|
composer.lock |
the license of each package, a list of the licences it is offered under |
package-lock.json, npm-shrinkwrap.json from lockfile version 2 |
the license of each package entry |
pnpm-lock.yaml, yarn.lock, a package-lock.json of version 1 |
the package's manifest in the registry the app's .npmrc names, npm view <package>@<version> license; the packages are found by walking the lock file from the dependencies of package.json, so a dev dependency is left out as in the audit |
The findings:
-
[!]licence-incompatible: a licence that cannot be combined with the app's, such asGPL-2.0-onlyin anAGPL-3.0-or-laterapp, or a GPL or AGPL package in an app under MIT or Apache-2.0. The finding says why, from the licence table -
[!]licence-non-free: a licence that forbids redistribution or commercial use, such asCC-BY-NC-4.0orUNLICENSED -
[!]licence-missing: a shipped package that states no licence -
[i]licence-unknown: a licence that is no SPDX expression,SEE LICENSE IN LICENSE.mdfor instance, or one the table gives no verdict on; it is shown as written. A licence the registry did not return is reported here as well -
[i]licence-summary: the shipped packages grouped by licence, for the release notes and the App Store listing
An expression with OR passes when one of its licences does, because the app can take the package under that one; one with AND passes when all of them do. An app may declare several <licence> elements, and a package then has to go with each of them. Without a licence in info.xml, or with one the table does not know, no compatibility is judged and the summary says so; non-free and missing licences are reported regardless.
The gate is the lint-licences workflow of ncmake-workflows, which fails on the three [!] findings.
Which licences go together is data, not code: lib/licences.json of ncmake, so it can be corrected without a change to the analyser.
| Key | What it holds |
|---|---|
groups |
every licence the table judges, in one group each: permissive, apache, weak-copyleft (LGPL-2.1, MPL-2.0), lgpl-3.0, gpl-2.0-only, gpl-2.0-or-later, gpl-3.0 and agpl-3.0
|
compatible |
for the group of the app's licence, the groups of a dependency licence that can be combined with it in one distributed work |
notes |
per group of a dependency licence, why it does not go with the others; the finding quotes it |
non-free |
the licences that forbid redistribution or commercial use |
aliases |
ids that are deprecated or not SPDX, mapped to the id they stand for: GPL-2.0 to GPL-2.0-only, agpl from info.xml to AGPL-3.0-or-later; null where the id does not say, as BSD
|
examples |
a licence, the licence of an app and the verdict, compatible, incompatible, non-free or unknown
|
Ids are matched regardless of case, as SPDX does, and an exception after WITH leaves the licence as it is, since it only adds permissions. A licence the table does not name gets no verdict: EPL-2.0, for instance, goes with the GPL only where the package names the GPL as a secondary licence, and that is not in its id.
Every example is tested before an app is judged, and an example that does not hold stops the run. make deps-audit ARGS=--self-test tests the examples and nothing else; the smoke workflow of ncmake runs it on every pull request, so a change to the table carries the examples that show it.
A private key, an API token or an .env file committed to an app repository is public as soon as it is pushed. It stays in the history after a later commit removes it, and when it sits in the shipped file set, in a vendor package or under keep_extra for instance, it is in every tarball on the App Store as well. secrets-audit runs gitleaks in two scopes:
| Scope | What it reads |
|---|---|
SCOPE=shipped |
the files make stage takes from the checkout, copied aside as make stage copies them; with TARBALL=<file> the tarball instead |
SCOPE=history |
every commit of the checkout, or the commits of COMMITS=<range>, a git log range such as main..HEAD
|
SCOPE=all, the default, checks both. Next to the rules of gitleaks, which know what a secret looks like inside a file, a file is reported by its name:
- a private key:
*.key,id_rsa,id_ed25519and their siblings (not*.pub), and a*.pemwith a private key block - a key store:
*.p12,*.pfx - an
.envfile, but not.env.example,.env.sample,.env.dist,.env.templateor.env.defaults, which hold no secret - the
auth.jsonof composer with a credential, and an.npmrcwith a token
The public certificate of the Integrity signature, .ncmake/certificate.crt, is committed on purpose and never reported; its key is, wherever it appears.
The findings:
-
[!]secret-shipped: a secret in what ships. Take it out of the release and revoke it -
[!]secret-in-history: a secret in a commit, with the commit that added it. It has to be revoked: removing it from the repository does not make it secret again, every clone has the commit -
[ok]no secret in a scope, with the number of files or commits it read
Each finding gives the rule, the file, the line and the commit, and never the secret: gitleaks runs with --redact, and a file found by its name is named, not shown. To find it, ARGS=--reveal adds the line of each finding to the report on the terminal, or for a file found by its name the lines that carry the credential; a key file is the secret as a whole and is not printed. What --reveal shows never reaches the JSON, and a run where CI is set refuses it, since a CI log is read by others. Both findings carry "security": true in the JSON, and a fingerprint, <commit>:<file>:<rule>:<line> in the history and <file>:<rule>:<line> in what ships. That fingerprint in the repository's .gitleaksignore silences a finding that is revoked and meant to stay, or that is no secret at all, a file found by its name included; the repository's .gitleaks.toml configures gitleaks for both scopes.
False positives. gitleaks takes some values for secrets that are none: the test secret of RFC 4226 and RFC 6238 in a unit test, for instance, which its rule generic-api-key reports. make secrets-ignore runs secrets-audit with the same SCOPE=, TARBALL=, COMMITS= and ARGS=, lists its findings numbered, asks which are false positives, by number, range (1 3-5) or all, and why, and appends the fingerprint of each to .gitleaksignore, under a comment that documents it:
# generic-api-key in tests/unit/OtpServiceTest.php:34, 072899102f2e (2026-06-26): RFC 6238 test secret
072899102f2e6f1c...:tests/unit/OtpServiceTest.php:generic-api-key:34
REASON=<text> gives the reason up front. A finding already in the file is not listed again, and without a terminal the target only lists. It writes the file and stops; COMMIT=1 commits it on the branch ncmake/secrets/ignore, PR=1 also pushes and opens the pull request. .gitleaksignore is outside the keep model and never ships. In an app set up for REUSE (LICENSES/, REUSE.toml or .reuse/dep5), a new .gitleaksignore starts with the copyright and licence REUSE.toml states for itself, as .gitignore and the other ignore files carry them, so make reuse stays green; an existing one without a licence header is named.
make tag and make dist run it. make tag checks what ships before its confirmation, make dist the staged tree before it packs it, both through the core's ship_check, and both stop on secret-shipped. NO_SECRETS_AUDIT=1 goes on regardless. The history is not checked there: an old leak that is revoked must not block every release. Its gate is the lint-secrets workflow of ncmake-workflows, which checks the commits of a push or a pull request.
gitleaks is the Linux binary of its GitHub release, pinned by digest in mk/audit.mk, kept in tools/ of the ncmake cache and run in the throwaway Node container, which carries git; the fetcher is the one of the Lint module, which make dev-init brings along. It reads no network. It exits 1 on a [!] finding.
ARGS is passed to the analyser; a bare word in it is the checkout to read (default: the current directory). For consistency-audit:
ARGS |
What it does |
|---|---|
| (none) | this app |
‑‑details |
adds the tables behind the findings |
"‑‑details ‑‑top=0" |
and no row limit in those tables |
‑‑json |
the whole result, findings included, and nothing else |
‑‑no-network |
leaves the links of info.xml unchecked, as NO_NETWORK=1 does |
‑‑help |
the analyser's own option list |
make consistency-audit NO_NETWORK=1 leaves the links of info.xml unchecked.
For build-verify:
ARGS |
What it does |
|---|---|
| (none) | this app, where its bundler config writes, else js/ and css/
|
‑‑build=dist |
another output directory |
‑‑details |
adds the per-file tables, with the byte counts of both builds |
‑‑once |
one build instead of two |
‑‑fresh |
the output was built by make build right before, on this working tree |
‑‑keep |
keeps the scratch tree and the log each build step writes |
‑‑json |
the whole result, findings included, and nothing else |
‑‑help |
the analyser's own option list |
For changelog-lint:
ARGS |
What it does |
|---|---|
| (none) | this app, this version |
‑‑nightly |
the section a nightly is read from |
‑‑json |
the whole result, findings included, and nothing else |
‑‑help |
the analyser's own option list |
For php-compat:
| What it does | |
|---|---|
| (none) | the floor and the ceiling |
PHP=all |
every minor in between too |
NEXT=1 |
and the newest PHP ahead of master |
ARGS=‑‑details |
every file behind the findings |
ARGS=‑‑json |
the whole result, findings included, and nothing else |
ARGS=‑‑help |
the analyser's own option list |
For deps-audit:
ARGS |
What it does |
|---|---|
| (none) |
composer.lock and the npm lock file |
‑‑details |
every advisory and package of a finding, not the first three, and every licence of the summary |
‑‑json |
the whole result, findings included, and nothing else |
‑‑no-advisories |
abandoned packages and licences only, no vulnerability; NO_ADVISORIES=1 says the same |
‑‑self-test |
the licence table against its examples, and nothing else |
‑‑help |
the analyser's own option list |
For secrets-audit:
| What it does | |
|---|---|
| (none) | what ships and every commit |
SCOPE=shipped |
what ships, as make tag checks it |
SCOPE=history |
every commit |
TARBALL=<file> |
a tarball as what ships, the one make dist packs for instance |
COMMITS=<range> |
the commits of a git log range, such as main..HEAD, instead of every commit |
ARGS=‑‑details |
every commit that changed a file found by its name |
ARGS=‑‑reveal |
the line of each finding, on the terminal only; refused where CI is set |
ARGS=‑‑json |
the whole result, findings included, and nothing else |
ARGS=‑‑help |
the analyser's own option list |
make consistency-audit
make build-verify ARGS="--details"
make changelog-lint
make build-verify ARGS=--json > verify.json
make build && make build-verify ARGS=--fresh
make consistency-audit JSON=audit.json
make build && make php-compat PHP=all
make build && make php-compat NEXT=1
make deps-audit ARGS="--details"
make secrets-audit SCOPE=history COMMITS=main..HEADEach of the six targets takes JSON=<file>, which writes the JSON of ARGS=--json to <file> as well, from the same run, and the report is printed as without it. The file can lie outside the app; it is replaced once the run is complete, and a run that stops on an error writes none.
--keep and the build logs only survive with RUNTIME=bare and Node on the host: in a container the scratch tree lives in the container's own /tmp and goes with it.
Only the app itself is mounted into the container, so auditing a checkout outside it needs RUNTIME=bare too.
make dev-init fetches mk/audit.mk along with the other developer modules, so someone who only clones your app to build it never sees these targets.
The analysers are dependency-free ESM files and share the report core with the Bundle analysis, so findings read the same way everywhere. They live in the per-machine ncmake cache, are fetched on first use and refreshed on the same TTL and ETag terms as the modules, and are mounted read-only at /ncmake into the throwaway Node container. The fetch happens only when one of the targets is on the command line, so no other target in any app pays for it. php-compat runs its PHP versions in the build images of ncmake and hands only their output to the analyser, and deps-audit runs its composer half, lib/deps-audit.php, in the build image of the PHP floor in the same way. secrets-audit gets the gitleaks binary mounted beside it, as lint-actions-security gets zizmor.
Why does build-verify flag a file in js/ when js/ is git-ignored? Because make dist packs the working tree: a stale build in it ships. The finding is about the bytes a release would carry, not about what git tracks.
What do I do with a differing file? make dist-clean && make build, then commit what the build wrote. Run build-verify again and the file is identical.
A file is named as not reproducible. Is that my bug? Not necessarily: a build that writes a timestamp or an unstable order into its output differs from itself. The verifier names such a file and passes no verdict on it, because nothing can be concluded from a build that does not reproduce.
Can CI run them? consistency-audit and changelog-lint cost a second, and all consistency-audit reads from the network is the list of release tags of nextcloud/server, the version.php of its master and, for an app with a package.json, the Node.js release schedule, besides one HEAD per link of info.xml, which makes them cheap anywhere. NO_NETWORK=1 leaves the links out where a runner should not reach them. build-verify installs and builds twice, so it belongs in a job that is allowed to take minutes. consistency-audit and build-verify are reporting targets, not gates: use ARGS=--json for a machine-readable result, or JSON=<file> to get it beside the report from the same run. After a make build in the same job, ARGS=--fresh tells build-verify so, and where that output matches its first build, the second build does not run. changelog-lint exits 1 on a [!] finding, so its job fails where the App Store would publish the release without its changelog; made a required check, that failure holds the merge back. php-compat needs the network for versioncheck.php and the images, and runs one image per PHP version; it exits 1 on a [!] finding as well, so it fits a job after make build. deps-audit exits 0 with findings; the jobs that gate on it, for advisories and for licences, read build/reports/deps-audit.json. Where the job log is public, NO_ADVISORIES=1 keeps the vulnerabilities out of it. secrets-audit reads no network and takes seconds; SCOPE=history COMMITS=<base>..<head> checks what a pull request adds.
© 2026 [ernolf] Raphael Gradenwitz · MIT-licensed · Report an issue
For app developers
- Getting started
- How ncmake understands your app
- Building and packaging
- Bundle analysis
- Build tools
- Checkout audit
- Lint
- Releasing
- Integrity signature
- Translations
- Per-app tuning
- Target reference
- AI assistant
CI workflows
App Store
For app users
Git
Background
Reference