Skip to content
ernolf edited this page Nov 1, 2026 · 6 revisions

🛡️ Lint

The lint workflows of ncmake-workflows as make targets. Each target collects the same files, runs the same tool in the same version and fails where its workflow fails, so a finding shows on your machine before it shows in a public CI log. make lint-actions asks whether the workflows of the repository are valid, make lint-actions-security whether one of them can be attacked, and one target per template asks the same of shell scripts, Containerfiles, Markdown, YAML, PHP and plain web files; make lint runs every lint target. The lint-actions-security workflow runs that very target on the runner.

Tip

TL;DR — make dev-init once per machine, then make lint (the first run fetches each tool once). What fails the workflow is [!] and exits 1, what passes it is [i]. ARGS=--details adds the files a target checked, ARGS=--json the machine-readable form, SARIF=<file> on make lint-actions-security the form GitHub code scanning reads, make help-<target> the full option list of a target.

🎯 The targets

Target Tool What it checks
make lint-actions actionlint, with shellcheck over the run: scripts whether every tracked workflow is valid
make lint-actions-security zizmor what makes a workflow or action definition attackable
make lint-containerfiles hadolint, with shellcheck over every RUN every tracked Containerfile and Dockerfile
make lint-markdown markdownlint-cli2 every tracked Markdown file
make lint-php-syntax php -l, once per PHP version every tracked PHP file
make lint-shell shellcheck every tracked shell script
make lint-vue-imports the analyser itself imports of the @nextcloud/vue root in the tracked source files
make lint-web ESLint, Stylelint and html-validate the plain JavaScript, CSS and HTML of a repository without a package.json
make lint-yaml yamllint every tracked YAML file
make lint all of the above every target of the module, one after the other

Every target reads the checkout, writes nothing into it and needs no token. Each is named after the workflow of ncmake-workflows it mirrors, see Workflows.

📂 Which files they read

The tracked files only, collected with git ls-files as the template collects them, so a file that is not committed is never checked; node_modules/ and vendor/ are left out where the template leaves them out. A repository without files of the kind passes. A directory that is not a git checkout is a failed run, exit 2.

A configuration of the tool in the repository wins, as it does on the runner. Without one, lint-markdown, lint-web and lint-yaml apply the default of their template, made for GitHub repositories (see Workflows).

  • lint-actions lints every YAML file under .github/workflows and .ncmake/workflows and every YAML file with a .properties.json beside it, the form of a workflow template. It reads .github/actionlint.yaml or .yml; without one it declares ubuntu-latest-low, the self-hosted runner of Nextcloud, as a known runner label.
  • lint-actions-security reads what lint-actions reads and every action.yml or action.yaml outside node_modules/ and vendor/, with .github/zizmor.yml or zizmor.yml.
  • lint-shell takes every *.sh and *.bash, and every other file whose shebang names sh, bash, dash or ksh. It reads .shellcheckrc.
  • lint-containerfiles takes Containerfile, Dockerfile, *.containerfile and *.dockerfile, such as Dockerfile.dev or app.Containerfile. It reads .hadolint.yaml or .yml.
  • lint-markdown reads a .markdownlint-cli2.* or .markdownlint.* file, lint-yaml a .yamllint, .yamllint.yaml or .yamllint.yml.
  • lint-vue-imports finds a static or dynamic import, a re-export, a require and a vi.mock or jest.mock of the root in .vue, .js, .mjs, .cjs, .jsx, .ts, .mts, .cts and .tsx files. An entry point such as @nextcloud/vue/components/NcButton passes.
  • lint-web passes a repository with a package.json, which lints through its own scripts, and leaves out minified *.min.js and *.min.css.
  • lint-php-syntax checks the PHP versions of PHP_VERSIONS, of a .php-version file or the default, separated by spaces, commas or semicolons; a word that is not digits and dots is refused: make lint-php-syntax PHP_VERSIONS="8.1 8.3". In an app the default is the PHP range its servers run on, the floor and the ceiling that php-compat derives from appinfo/info.xml and the lib/versioncheck.php of the servers; outside an app it is 8.2 8.4.

🚦 What fails and what passes

[!] marks what fails the template and exits 1, [i] what passes it:

  • every error of actionlint and every finding of shellcheck and of lint-vue-imports is [!]
  • a warning of ESLint, Stylelint or markdownlint is [i], an error [!]
  • a warning of yamllint is [i], unless the yamllint configuration sets strict
  • a hadolint finding below its failure-threshold is [i]; the threshold is info unless the configuration sets one
  • a parse or fatal error of php -l is [!] under the version that reports it, a warning or deprecation [i]; a line php -l prints about a file without one of those kinds, as when the parse runs out of memory, is [!] when php exits with an error. php -l runs without a memory limit, in the template as in the target
  • zizmor: see below

Each finding names the file, the line, the rule and the page of the rule, which says how to fix it or how to ignore it.

🔐 What lint-actions-security checks

zizmor reads GitHub Actions workflows for what makes them attackable: pull_request_target or workflow_run running the code of a pull request, ${{ github.event.* }} expanded into run:, an action not pinned to a commit, permissions broader than the job needs, credentials that actions/checkout leaves behind, cache poisoning in a release workflow. It runs offline, so it needs no token and reads nothing but the checkout, and it reads the repository's own .github/zizmor.yml or zizmor.yml when there is one, as it does on the runner.

Findings of medium and high severity are [!] and exit 1, as they fail the workflow; low and informational ones are [i]. Each names the file and line, the severity and confidence zizmor gives it, and the page of the zizmor audit, which says how to fix the finding or how to ignore it. A finding the repository's configuration or an inline # zizmor: ignore[...] ignores is left out.

📦 Where the tools come from

make dev-init fetches mk/lint.mk along with the other developer modules. Every tool version is pinned in that module and nowhere else.

Tools Source Check
actionlint, shellcheck, hadolint, zizmor the Linux binary of the GitHub release, for x86_64 or aarch64 the sha256 digest the module pins
yamllint, with pathspec and PyYAML the source distributions from PyPI, run on the Python of the analysers' Node image without pip the sha256 digest the module pins
markdownlint-cli2, ESLint, Stylelint, html-validate npm, installed in the analysers' container the integrity the registry records for the pinned version
php -l the image docker.io/library/php:<version>-cli-alpine, one per version —

All but PHP are kept in tools/ of the per-machine ncmake cache, fetched once per version and mounted read-only into the throwaway Node container of the analysers, next to the analyser itself, so the host needs none of them and nothing is written into the checkout. A newer version replaces the older one once no ncmake container runs. make cache-clean lists the fetched tools as entries of their own. The image ghcr.io/zizmorcore/zizmor is not used: on an older x86-64 CPU its binary stops with an illegal instruction, while the release binary runs.

With RUNTIME=bare the tools run on the host directly. An npm package whose engines.node the host's Node does not meet stops the run with that range, and lint-php-syntax runs the host's php<version>, or its php when that is the version, and reports a version it has neither for as not run.

🧺 make lint

make lint runs every lint target of the module, one after the other, each in a make of its own and the way it runs alone. A target that fails does not keep the next one from running, and make lint fails when one failed. ARGS, JSON= and SARIF= belong to a single target, and make lint refuses them.

⚙️ Options

Option Targets What it does
ARGS=--details every lint-* lists the files that were checked
ARGS=--json every lint-* the full result as JSON, nothing else
JSON=<file> every lint-* the report as usual, and the JSON in <file> from the same run
PHP_VERSIONS="<versions>" lint-php-syntax the PHP versions to check, instead of .php-version or the default
ARGS=--persona=pedantic lint-actions-security zizmor's code smells as well; auditor shows everything, regular is the default
SARIF=<file> lint-actions-security the report as usual, and the findings as SARIF in <file>, for GitHub code scanning

Every finding of lint-actions-security in the JSON carries "security": true, because it says how a workflow can be attacked. The JSON and the SARIF file can lie outside the checkout; each is replaced only once it is complete, and a run that stops on an error writes neither.

❓ FAQ

Does my app need the workflows for this? No. The targets check whatever the repository has, installed by ncmake or not.

Why the release binaries and not a package manager? They are the builds the projects publish, their digests are pinned in the module, and they need nothing on the host. The same files run on the runner.

How do I ignore a finding I accept? As the tool documents it on the page each finding links: for zizmor an inline # zizmor: ignore[<audit>] comment or an entry in .github/zizmor.yml, for shellcheck a # shellcheck disable=<code> directive or .shellcheckrc, for the others their configuration file. The targets read all of them.

Why does lint-web pass my app? An app with a package.json lints its JavaScript and CSS through its own scripts, lint-eslint among them; lint-web is for a repository that ships plain web files without a build.

Clone this wiki locally