Repository navigation
Workflows
This guide covers everything about GitHub Actions workflows in an ncmake app: the workflow manager that installs and updates them, and the release workflow ncmake ships itself.
Tip
TL;DR β make dev-init once per machine, then make workflows-list shows every available workflow with its status, make workflows-install W="reuse lint-php" installs the ones you pick, and a make workflows-update from time to time keeps them current. Both take an optional COMMIT=1 (commit on a branch) or PR=1 (commit and open the pull request). Commit .github/workflows/ including the .ncmake-workflows.json lock file.
- The developer module
- Where the workflows come from
- The workflows ncmake ships
- Workflows of your own
- Further sources
- Listing, installing, updating
- Committing and opening a PR:
COMMITandPR - Keeping them up to date automatically
- The lock file
- Placeholders and runner labels
- The ncmake release workflow
- FAQ
The workflow manager is not part of the core Makefile: it is a developer module. Someone who clones your app to build and install it never needs it, so it stays out of their make help. As the maintainer you fetch the modules once per machine:
make dev-initThis discovers all modules the ncmake repo ships (mk/*.mk), caches them next to the core Makefile in ~/.cache/ncmake/ and loads them on every subsequent make run β in every ncmake app on this machine. The cache keeps itself current with the same TTL/ETag mechanism as the core Makefile, and NCMAKE_REF pins the version, as everywhere. make dev-clean removes the modules again; make help then shows the plain user target set.
Important
Like the core Makefile, modules never land in your repository. The only workflow-related file your repo carries is what you deliberately install into .github/workflows/ β plus its lock file, see below.
The manager knows three sources, and looks them up in this order:
| Source | Content |
|---|---|
local |
the folder .ncmake/workflows/ of your app: workflows of its own, if it has any |
ncmake |
the templates of ernolf/ncmake-workflows, see below: the release workflow, the updater, the backport command, the branch cleanup and eight generic lint gates |
nextcloud |
the official nextcloud/.github workflow templates: lint-php, lint-info-xml, psalm-matrix, phpunit, reuse, block-unconventional-commits and many more |
Discovery is live: the manager lists the sources via the GitHub API at run time, so a workflow added upstream appears in make workflows-list immediately β no ncmake update involved. On a name collision the first listed source wins: a workflow of your own overrides a template of the same name, and an ncmake template a Nextcloud one. A source you add yourself ranks between local and ncmake.
The Nextcloud templates are worth a special note: they are largely self-configuring. lint-php.yml, for example, computes its PHP version matrix at CI run time from your appinfo/info.xml (via the nextcloud-version-matrix action), so the very same file works in every app β the same DRY principle the whole of ncmake is built on. Most templates install without any per-app patching.
The ncmake source carries twelve workflows. They live in ernolf/ncmake-workflows, which lints them and keeps their actions current with Dependabot, so a template changes without an ncmake release. Four drive ncmake's own machinery, eight are generic lint gates that the Nextcloud templates do not cover:
| Workflow | What it does | Check to require |
|---|---|---|
release.yml |
builds the release tarball and attaches it to the release, see below | β |
workflowβupdater.yml |
opens the pull request that keeps the managed workflows current, see Workflow updater | β |
backport.yml |
opens a backport pull request against a maintenance line on a /backport to <branch> comment, see Backport
|
β |
branchβcleanup.yml |
deletes the head branch after its pull request is merged, see Deleting merged branches | β |
lintβshell.yml |
shellcheck over every tracked shell script |
shellcheck |
lintβphpβsyntax.yml |
php -l over every tracked PHP file, on a version matrix |
php-lint-summary |
lintβvueβimports.yml |
refuses an import of the @nextcloud/vue package root instead of a per-component entry point |
@nextcloud/vue root imports |
lintβweb.yml |
ESLint, Stylelint and html-validate over the JavaScript, CSS and HTML of a repository without a package.json
|
ESLint, Stylelint, html-validate
|
lintβyaml.yml |
yamllint over every tracked YAML file |
yamllint |
lintβactions.yml |
actionlint over every tracked GitHub Actions workflow |
actionlint |
lintβcontainerfiles.yml |
hadolint over every tracked Containerfile and Dockerfile |
hadolint |
lintβmarkdown.yml |
markdownlint-cli2 over every tracked Markdown file |
markdownlint |
The lint gates find their own work with git ls-files, so there is nothing to configure and no path to list, and a repository that has nothing of that kind in it passes instead of failing. None of them reads appinfo/info.xml or composer.json, so they fit a plain repository just as well as a Nextcloud app.
lint-shell takes *.sh and *.bash by extension and every other tracked file by shebang, so an extensionless command in bin/ is linted too, and it runs shellcheck -x so a sourced library of functions is followed instead of being reported as an unknown source.
lint-php-syntax exists because the Nextcloud template lint-php.yml cannot run outside a Nextcloud app: it derives its matrix from appinfo/info.xml and calls composer run lint. This one needs neither. The different name is deliberate: ncmake wins a name collision, so a generic lint-php.yml here would silently replace the Nextcloud template in every app that installs it.
Which PHP versions are checked is resolved at run time, in this order:
| Order | Source | How to set it |
|---|---|---|
| 1 | the repository variable PHP_VERSIONS
|
gh variable set PHP_VERSIONS -b "8.2 8.4" |
| 2 | a .php-version file in the repository |
one or more versions in the file |
| 3 | in a Nextcloud app, the PHP range of its servers | the floor and the ceiling make php-compat derives from appinfo/info.xml and the lib/versioncheck.php of the servers |
| 4 | the template default, outside an app |
8.2 8.4, edited in ncmake-workflows |
Spaces, commas and semicolons all separate. Keeping the versions out of the file is what keeps the file byte-identical in every repository: the lock hash goes on matching, the status stays installed, and a PHP bump is a single edit in ncmake-workflows that the updater carries into every app. The matrix being dynamic has one consequence for branch protection: the matrix job names carry the version (php-lint 8.4) and change with it, so the check to require is the stable summary job php-lint-summary, never a matrix leg.
lint-vue-imports fails a source file that reaches @nextcloud/vue through the package root, because the root pulls the whole library into the module graph and what survives is then a question of tree shaking rather than of what the app asked for. The per-component entry point is the fix:
import NcButton from '@nextcloud/vue/components/NcButton'The gate matches the specifier in its syntactic context (from, import, require(, mock(), so an entry-point path, a commented-out line and a config file that merely names the package in a rule all pass, and node_modules/ and vendor/ are skipped. In an app without a frontend it has nothing to check and reports so. What a root import actually costs in delivered bytes is what Bundle analysis measures.
lint-web lints the hand-written JavaScript, CSS and HTML of a repository that has no Node project, such as a static site or a few scripts in a tool repository. The Nextcloud templates lint-eslint and lint-stylelint run npm ci and the repository's own lint script, so they need a package.json. This workflow brings ESLint, Stylelint and html-validate itself, each at a version pinned in the file, and runs each in a job of its own. A repository that does have a package.json lints through its own scripts and passes here with a note, so nothing is linted twice. Minified *.min.js and *.min.css files and tracked node_modules/ and vendor/ trees are skipped.
Without a configuration in the repository each tool uses its recommended set: @eslint/js recommended with the browser globals, in a Nextcloud app (appinfo/info.xml present) also with the globals of the Nextcloud server as @nextcloud/eslint-config declares them (OC, OCA, OCP, t, n and a few more), stylelint-config-standard and html-validate:recommended. A configuration in the repository root replaces it: eslint.config.*, any Stylelint configuration file, .htmlvalidate.* or html-validate.config.*. The tools are linked into the checkout as node_modules, so a configuration that imports @eslint/js or globals resolves them without a package.json of its own. A site whose scripts share globals declares them there:
// eslint.config.mjs
import js from '@eslint/js'
import globals from 'globals'
export default [
js.configs.recommended,
{ languageOptions: { globals: { ...globals.browser, uPlot: 'readonly' } } },
]The .mjs extension makes the file an ES module whether or not the repository has a package.json that says so.
lint-yaml, lint-actions and lint-markdown each take the repository's own configuration when it has one in the root and otherwise a default set made for GitHub repositories.
-
lint-yamlreads.yamllint,.yamllint.yamlor.yamllint.yml. Its default is yamllint's own set, except that theon:key of a workflow is not a truthy value, long lines are allowed and the---document marker is optional. -
lint-actionslints every YAML file in.github/workflows/, in.ncmake/workflows/and next to a<name>.properties.json, which is the layout of a workflow source such as ernolf/ncmake-workflows: a template is checked in the repository it is written in. actionlint runs shellcheck over therun:scripts and reads.github/actionlint.yamlwhen the repository has one. -
lint-markdownreads a.markdownlint-cli2.*or.markdownlint.*file. Its default is markdownlint's rule set without the rules GitHub Markdown breaks on purpose: unwrapped prose (MD013), embedded HTML such as badges and sized images (MD033), a license comment before the first heading (MD041), consecutive alerts (MD028), heading anchors with emoji (MD051) and tables without padded pipes (MD060). A heading may repeat under another parent (MD024siblings_only), as a changelog repeats its sections in every release. Trackednode_modules/andvendor/trees are skipped.
lint-containerfiles picks a file by its name: Containerfile or Dockerfile, alone or with a suffix or prefix such as Dockerfile.dev or app.Containerfile. hadolint runs shellcheck on every RUN and reads the repository's .hadolint.yaml or .hadolint.yml when there is one. The hadolint image is pinned by digest, so every run lints with the same version.
lint-actions-security runs make lint-actions-security of the Lint module, the target you run on your own machine, so both give the same result: zizmor offline over the workflows lint-actions lints and every tracked action.yml, with the repository's .github/zizmor.yml or zizmor.yml when there is one. Findings of medium and high severity fail the job, and the report goes to the job summary. The job fetches the ncmake bootstrap into the runner's temp directory, so a repository needs none of its own. The repository variable ZIZMOR_CODE_SCANNING set to true adds a job that uploads the findings to code scanning; only that job holds security-events: write.
A workflow that only your app needs lives in the folder .ncmake/workflows/ of the app, as <name>.yml, optionally with <name>.properties.json next to it for the description in make workflows-list:
{
"name": "Check the fixtures",
"description": "Validate the test fixtures against the schema"
}make workflows-install W=<name> installs it into .github/workflows/ like any template: placeholders are substituted, runner labels rewritten, the lock records it with the source local. The folder is read from the checkout, so it needs no network and no configuration; wf_local_dir in ncmake.mk names another folder.
The point of the folder is that Dependabot can keep it current without ever touching .github/workflows/. With any directory other than /, Dependabot reads only the YAML files of that one directory:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: "/.ncmake/workflows"
schedule:
interval: weeklyA change there shows as update available, and make workflows-update, or the workflow updater, installs it. The managed files in .github/workflows/ stay as the manager wrote them, so they never turn modified. Once such a workflow is generic, it belongs in ernolf/ncmake-workflows.
Any GitHub repository with the same layout, workflows/<name>.yml and optionally workflows/<name>.properties.json, is a source in one line of ncmake.mk:
wf_sources += acme/ncmake-workflows
wf_sources += acme/ci@v2:templatesThe form is owner/repo[@ref][:dir]; dir defaults to workflows and ref to the default branch of the repository. A source you add ranks after local and before ncmake and nextcloud, in the order you add them. A source runs code in your CI, so the manager reads only the sources you name, never any it finds on its own, and the lock hash shows when an installed file drifts from what was fetched.
For anything not shaped like a GitHub repository, add a name instead and define its two URLs, the listing (GitHub contents API format) and the prefix the files are downloaded from:
wf_sources += mine
wf_src_mine_list = https://api.github.com/repos/acme/ci/contents/templates?ref=main
wf_src_mine_raw = https://raw.githubusercontent.com/acme/ci/main/templatesmake workflows-listprints every workflow of every source with its status:
| Status | Meaning |
|---|---|
| (empty) | available, not installed |
installed |
installed and identical to the recorded state, upstream unchanged |
updateΒ available |
upstream changed since the install β workflows-update picks it up |
modified |
you edited the local file β never overwritten by workflows-update
|
missing |
in the lock file but deleted locally β reinstalled by workflows-update
|
unmanaged |
present in .github/workflows/ but not installed through ncmake |
goneΒ upstream |
managed, but the source no longer offers it |
make workflows-install W="reuse lint-php lint-info-xml"fetches the named workflows (the .yml suffix is optional), substitutes placeholders, writes them into .github/workflows/ and records them in the lock file. Installing over an existing file overwrites it β that is also how you adopt an unmanaged file into management, or reset a modified one back to upstream.
make workflows-updatebrings every managed workflow to the current upstream state in one go: outdated and missing files are reinstalled, locally modified ones are skipped with a note, current ones are left alone. After installing or updating, review the diff and commit:
git add .github/workflows/By default both workflows-install and workflows-update do exactly one thing: they rewrite the files in .github/workflows/ and leave the commit to you. That is deliberate β it is what the automatic updater drives, so the plain form must stay side-effect-free. When you want the full move in one step, two flags take you the rest of the way. They work identically on both targets:
| You run | Refresh files | Branch + commit | Push | Open PR |
|---|---|---|---|---|
makeΒ workflowsβupdate (default)
|
β | |||
makeΒ workflowsβupdateΒ COMMIT=1 |
β | β | ||
makeΒ workflowsβupdateΒ PR=1 |
β | β | β | β |
makeΒ workflowsβinstallΒ W="β¦" (default)
|
β | |||
makeΒ workflowsβinstallΒ W="β¦"Β COMMIT=1 |
β | β | ||
makeΒ workflowsβinstallΒ W="β¦"Β PR=1 |
β | β | β | β |
COMMIT=1 β commit on a branch, stop before pushing. Starting from main or a stable* branch, it creates the branch (ncmake/ci/workflow-update for update, ncmake/ci/workflows-install for install), refreshes or installs there, and commits with a Signed-off-by. The commit message is a fixed first line plus one bullet per file that actually changed, read straight from the staged diff:
ci: update managed CI workflows from upstream
- reuse.yml
- lint-php.yml
Then it prints the exact git push command and stops β nothing leaves your machine. This is the whole point of the separate flag: you can inspect the commit, git commit --amend to add or adjust something, and push when you are ready. If nothing changed, the branch is discarded and the branch you started from is left untouched.
Before it starts, it checks that the branch does not already exist β locally or on origin. If it does, it stops immediately, without refreshing, committing or pushing, and tells you how to proceed: a branch on origin may carry an open pull request, or be a merged one that was never deleted. Either merge or close its pull request, or delete the branch (git push origin --delete <branch>), then run the target again. It never deletes a branch for you, since an existing one may well be intentional.
PR=1 β commit, push, open the pull request. It implies COMMIT=1 (so everything above happens), then pushes the branch and opens the PR with the GitHub CLI. The pull request targets the branch you started from. The commit's first line becomes the PR title. The body is the same bullet list, a horizontal rule, and below it one paragraph per workflow with the description its source gives, followed by a sentence on what ran. BODY=<path> replaces the text below the rule with the content of that file, which keeps markdown and backticks intact where a command line would not. PR=1 needs gh installed (make gh-install) and authenticated (gh auth login); if either is missing you get a clear message and the commit still waiting on its branch with the push command shown, so nothing is lost. After you merge the pull request its ncmake/ci/β¦ branch stays around unless you have GitHub remove merged branches for you β see Automatically deleting merged branches.
Important
The commit is signed from your git config (commit.gpgsign = true), exactly like make version. If your branch protection requires verified signatures, set that up once (git config --global commit.gpgsign true and a configured signing key) or the pushed commit cannot be merged.
On a maintenance branch. A stable* branch keeps the workflows of the tag it started from (Maintenance lines). When main gained a workflow later, for example one whose check your repository rules require, bring it over on the line:
git switch stable0.7
make workflows-install W=phpunit-mariadb PR=1
On stableX.Y the branch carries the line as a suffix, here ncmake/ci/workflows-install-stable0.7, so it never collides with a branch for main that is still open, and the pull request goes against stable0.7. The automatic updater below only ever runs on the default branch, so make workflows-update PR=1 on the line is yours to run from time to time.
For the unattended equivalent of PR=1 β a signed, verified PR opened for you on a schedule with no local gh β see the automatic updater next.
Running make workflows-update by hand works, but ncmake also ships a workflow that does it for you. The workflow updater runs make workflows-update on a daily schedule and opens a pull request whenever a managed workflow has changed upstream. It is what replaces Dependabot for .github/workflows/. See The workflow updater for installing it and the one-time token it needs.
.github/workflows/.ncmake-workflows.json records, per managed workflow, the source it came from, the upstream blob sha at install time and the hash of the installed content. Those two fingerprints are what makes the status column possible: a differing upstream sha means update available, a differing content hash means modified.
The recorded hash is the hash of the file as written β after placeholder substitution and the runner rewrite below. Those transforms are deterministic, so a re-install of an unchanged upstream produces the identical bytes and the status stays installed; only a real upstream change or a hand edit moves it.
Next to the lock the manager writes .ncmake-workflows.json.license, a REUSE sidecar that licenses the generated JSON (which cannot carry an SPDX header of its own). It defaults to CC0-1.0 β the license Nextcloud apps put on generated files β with a copyright line whose year is read from the clock at generation time, so a file regenerated in a later year updates on its own. Override wf_lock_license and wf_lock_copyright in ncmake.mk. This keeps make reuse green without any REUSE.toml edit.
Commit the lock file and its .license sidecar. They contain no secrets, and with them in the repository every machine β and every co-maintainer β sees the same status and can run workflows-update. Without the lock the manager would consider all workflows unmanaged.
GitHub's workflow templates may contain placeholders in the form $default-branch (lowercase, hyphenated). The manager substitutes the ones it knows at install time:
| Placeholder | Replaced with |
|---|---|
$defaultβbranch |
the default branch of your origin remote (falls back to main) |
Unknown placeholders of that form are left as-is and reported with a warning, so a new upstream placeholder never breaks the install β you edit the file manually and the manager treats your edit as modified from then on. Everything else in the files β ${{ ... }} expressions, shell variables in run: blocks β is none of the manager's business and passes through untouched.
Runner labels. The Nextcloud templates run on the org's own runner pool, with labels like ubuntu-latest-low. These are configured at the organization level and are therefore org-scoped: a repo outside that org has no such runner, so a job on that label would queue forever and never turn green. On install the manager rewrites them to their GitHub-hosted equivalent:
| Rewritten from | to |
|---|---|
ubuntuβlatestβlow |
ubuntu-latest |
Whether to rewrite is decided automatically from the owner of your origin remote. A repo inside the org that owns those runners keeps the labels (it really has them); every other repo gets the rewrite. So a nextcloud/β¦ repo and your own you/β¦ repo both do the right thing with no configuration. The org is wf_runner_org (default nextcloud); the rules are wf_runner_rewrite (space-separated old=new pairs). Override either in ncmake.mk:
wf_runner_org = my-org # keep the org labels for repos under my-org
wf_runner_rewrite = # or: never rewrite, regardless of owner
wf_runner_rewrite = big=small a=b # or: your own rewrite rulesThe rewrite is applied before the file is hashed, so it is invisible to the status model β a rewritten file is still installed, and workflows-update keeps it so.
Tip
This is why the templates are installed through the manager rather than copied by hand: the same fetch that finds updates also localizes the org-specific bits (branch name, runner labels) and keeps the file REUSE-compliant.
release.yml, one of the templates of the ncmake source, builds the release tarball in CI. It carries no app-specific data: the shipped file set comes entirely from ncmake (keep model + .nextcloudignore), so the file is byte-identical across all ncmake apps.
The workflow triggers on release: published (attaches <app_id>-<version>.tar.gz to the release) and on workflow_dispatch (produces the same tarball as a downloadable artifact for inspection, without publishing). The whole build is make build and then make dist; the tarball is located by glob, so nothing in the file names the app.
For an app marked as signed it does two more things on a published release. It reads the gist id out of the tag message and fetches appinfo/signature.json from there β unauthenticated, so nothing about signing needs a secret or a wider permission β and it runs make integrity-check in front of make dist, so nothing is packed that did not verify. A release whose tag names no signature while .ncmake/certificate.crt is committed fails the job instead of attaching an unsigned tarball. It also prints the age of the tag into the job summary. The full flow, and why the signing key stays out of CI: Integrity signature.
make runs on the runner host, not in a job container: ubuntu-latest already carries podman (which ncmake uses for the build containers) plus git, curl, rsync, tar and python3, so the workflow needs no setup-* steps and no toolchain of its own. The build runs in a job that may only read the repository, because it runs the app's composer and npm steps. It hands the tarball over as an artifact to a second job, run for a published release only, which holds contents: write for the upload and runs nothing but the download and gh release upload. No secrets beyond the automatic GITHUB_TOKEN.
Like the bootstrap stub, the file carries ncmake's MIT header and is committed verbatim; the LICENSES/MIT.txt you already have for the stub covers it for REUSE.
Which workflows should a typical ncmake app install? The set the ncmake reference apps use: release, workflow-updater, branch-cleanup and lint-shell from the ncmake source, plus reuse, lint-php, lint-info-xml, lint-php-cs, psalm-matrix and block-unconventional-commits from the Nextcloud templates. Apps with a frontend add lint-eslint, their node workflow and lint-vue-imports; apps with PHPUnit tests add a phpunit template. A repository that is not a Nextcloud app takes lint-php-syntax in place of lint-php, and lint-web when it carries JavaScript, CSS or HTML without a package.json.
Can I keep hand-written workflows next to the managed ones? Yes. Files the manager did not install show up as unmanaged in the list and are never touched by workflows-update.
How do I pin the workflow versions? The lock file already pins what is installed β nothing changes until you run workflows-update or workflows-install. The ncmake source reads main of ernolf/ncmake-workflows; NCMAKE_WORKFLOWS_REF=<ref> reads another branch or a tag instead, and @<ref> does the same for a source of your own.
What about private repos or API rate limits? The listing costs one GitHub API call per remote source; anonymous, that is 60 requests per hour per IP, which is plenty for the occasional list/install. Where the environment offers GITHUB_TOKEN or GH_TOKEN, the manager sends it and the limit is 5000. The downloads themselves come from raw.githubusercontent.com and are not rate-limited in that way.
Β Β© 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