Skip to content
dataheldPublic

About

Opinionated πŸ€“, batteries-included πŸ”‹, extra-DRY 🀌 Nix boilerplate.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

552 Commits

Folders and files

Repository files navigation

nullkomma βš‘οΈβ„οΈ

built with nix FlakeHub

Opinionated πŸ€“, batteries-included πŸ”‹, extra-DRY 🀌 Nix ❄️ boilerplate.

nullkommanix [Ιͺn ˈnʊl ˌkΙ”ma ˈnΙͺΓ§tΝ‘s] noun German colloquialism (translation: in next to time).

Scope

nullkomma is per-project Nix scaffolding for devshells, formatters, checks, tasks, CI and editor settings, packaged as composable flake-parts modules.

It is not:

Concern Lives in
Fleet placement, clan, networking, fleet secrets dataheld/aoshima
Host baseline (boot, disk, hardening, host services) dataheld/pads
User environment policy (home-manager, shell ergonomics) dataheld/lap
Agentic coding app / runtime dataheld/gittens

Charter

This repo contributes to the broader IT infrastructure laid out in aoshima. When making changes, adhere to aoshima's charter.

Getting started

Prerequisites

  1. Install Nix; the Determinate Nix Installer is recommended. On Windows, use the Windows Subsystem for Linux (WSL).
  2. For automatic shell activation, install direnv and nix-direnv in your user environment, e.g. with lap. Set hide_env_diff = true to quiet it.

Everything else comes from your project's flake.

Adding nullkomma to a project

In your project, run:

nix flake init --template "https://flakehub.com/f/dataheld/nullkomma/0.1.*#default"
git add flake.nix .envrc
nix develop --command nk-write-files
nix develop --command nk-generate-files
direnv allow

The template adds flake.nix and an .envrc with use flake, keeping existing files; nk-write-files and nk-generate-files add the files that depend on your configuration. From now on, entering the directory loads the development shell; the first time takes a while.

The template's nixpkgs.follows = "nullkomma/nixpkgs" uses nullkomma's tested Nixpkgs. To use your own, declare nixpkgs.url and set nullkomma.inputs.nixpkgs.follows = "nixpkgs".

Upgrading

Run update: it updates flake.lock, then runs nk-write-files and nk-generate-files. After any other nix flake update (e.g. by a bot), run those two yourself. After editing flake.nix, run direnv reload.

Configuring

nullkomma.lib.mkFlake is flake-parts' mkFlake with every plugin imported and the default ones enabled, so your flake.nix lists only exceptions and options.

For example:

inputs.nullkomma.lib.mkFlake { inherit inputs; } {
  # All plugins and their settings: see "Plugins" below.
  nullkomma.quarto.enable = true; # opt in
  nullkomma.neovim.enable = false; # opt out
  perSystem =
    { pkgs, ... }:
    {
      nullkomma.devShell.packages = [ pkgs.hello ];
    };
}

Plugins

Each plugin is an independently selectable integration exported as flakeModules.<name>; each column is an output of your flake that it adds to. Optional features belong to their plugin, not to separately selected companion plugins. All plugins are optional; those marked under Default are enabled unless you opt out. Files and editor settings that plugins write into your repository are under Plugin details.

Plugin Default devShells formatter1 checks packages apps2 taskRunners processTrees3
framework always every task, tasks runner treefmt treefmt task outputs check (when checks exist), format, nk-write-files, nk-generate-files, watch-<task>, pc-up-<tree> watched/process tasks and groups
nix βœ… nixd nixfmt, deadnix, nixf-diagnose nix-unit update, flake-checker 4
markdown βœ… marksman mdformat, rumdl
json βœ… JSON language server jsonfmt
yaml βœ… yaml-language-server yamlfmt, yamllint, actionlint
toml βœ… toml-sort
shell βœ… bash-language-server shfmt, shellcheck bats 5
github βœ…
agents βœ…
keepSorted βœ… keep-sorted
gitleaks βœ… gitleaks secret-history
typos βœ… typos
jscpd βœ… jscpd
git βœ… git
direnv βœ…
vscode βœ…
neovim βœ…
flakehub βœ… fh
quarto (includes nkdocs; optional brand) quarto site build-site, preview, pc-up-preview preview
cloudflare wrangler cloudflare (OpenTofu validation) deploy6 terraform, deploy
r (optional non-Nix collaboration, pkgdown brand) R, radian, Air; languageserver, pkgload, pak, httpgd, later, rstudioapi air, jarl r-testthat, r-cmd-check, r-namespace, r-pkgdown site (pkgdown) r-lock, shiny (opt-in) r-namespace, r-document, r-test, r-check, r-pkgdown, r-pkgdown-preview, r-coverage, r-lock, shiny (opt-in); pc-up-<tree> r-namespace, r-document, r-test, r-check, r-pkgdown, r-pkg; r-coverage, shiny, r-shiny (opt-in)

Your flake outputs

These are your project's flake outputs; a schemas output describes them to Determinate Nix's nix flake show. Headings omit the system, which perSystem and commands like nix build .#site fill in: site is packages.<system>.site.

devShells

default

The development shell holds:

  • every task as a command, taking arguments (e.g. nk-write-files --accept <path>);
  • the tasks runner;
  • language servers and the formatter for editors;
  • the plugins' and your perSystem.nullkomma.devShell.packages.

formatter

format (or nix fmt) runs treefmt with every enabled plugin's formatters and linters; nix flake check checks the same through checks.treefmt. Editors format with treefmt too (VS Code: treefmt).

Generated files aren't formatted or linted: managed files and directories, and R/pkgdown and Quarto build outputs. Add other generated content to nullkomma.generatedPaths as treefmt globs, e.g. [ "generated/**" ].

Customizing linting

Configure linters in Nix, not in config files. Native config files are ignored (Jarl, which can't ignore them, fails instead); suppression comments such as # shellcheck disable=SC2086 still work. Your settings override plugins' lib.mkDefault values, and lists concatenate (lib.mkForce replaces one).

Linter Nix configuration Settings to use
yamllint perSystem.treefmt.programs.yamllint.settings Attributes matching yamllint's YAML config
typos nullkomma.lint.typos Attributes matching typos' TOML config
rumdl nullkomma.lint.markdown Markdownlint rule IDs: MD013 = false; or rule settings
actionlint nullkomma.lint.actionlint Attributes matching actionlint's YAML config
gitleaks nullkomma.lint.gitleaks Attributes matching its TOML config; upstream rules are retained by default
jscpd nullkomma.lint.jscpd Attributes matching its JSON config; threshold defaults to zero, ignores extend shared exclusions
ShellCheck perSystem.treefmt.programs.shellcheck treefmt-nix options, e.g. severity and extra-checks
deadnix perSystem.treefmt.programs.deadnix treefmt-nix switches, e.g. no-lambda-arg
nixf-diagnose perSystem.treefmt.programs.nixf-diagnose treefmt-nix options, e.g. ignore
Jarl perSystem.treefmt.settings.formatter.jarl.options CLI arguments, e.g. [ "--ignore=any_is_na" ]; file-only settings are not supported

For example, inside your mkFlake module:

nullkomma.lint = {
  typos.default.extend-words.foobaar = "foobaar";
  markdown.MD013 = false;
  jscpd.threshold = 5;
};

perSystem = { ... }: {
  treefmt.programs.yamllint.settings.rules.quoted-strings.required = true;
  treefmt.programs.deadnix.no-lambda-arg = true;
  treefmt.settings.formatter.typos.excludes = [ "snapshots/**" ];
  treefmt.settings.excludes = [ "vendor/**" ];
};

treefmt.settings.excludes skips files for every tool, jscpd included; see treefmt-nix's options for more. When migrating, move linter settings here and delete the old config files and pyproject.toml sections.

checks

treefmt

Runs the formatter and fails on formatting changes or lint errors.

<name>

Checks in perSystem.nullkomma.checks.<name>, e.g. test suites, run in your working tree with check, and sandboxed against the flake's source in nix flake check and CI, with extra sandboxArgs. Plugins add their own (see the checks column above); so can you:

perSystem = { pkgs, ... }: {
  nullkomma.checks.greeting = {
    packages = [ pkgs.hello ];
    command = "hello | grep -q Hello";
  };
};

tests

With the nix plugin, the nix-unit tests in perSystem.nullkomma.nix.unit.tests, exported as tests.systems.<system> (nix-unit's convention) and run by the nix-unit check.

packages

site

quarto or the r plugin's pkgdown builds packages.site; if both are enabled, set perSystem.packages.site yourself.

Brand

To theme sites from a brand flake exposing lib.brand (a brand.yml), declare its input and select it:

inputs.brand = {
  url = "github:example/brand";
  inputs.nullkomma.follows = "nullkomma";
};
# Inside mkFlake's module:
nullkomma.brand = inputs.brand;

Brand owns the declarations; nullkomma owns their translation and placement. R and Quarto keep their native themes unless their branding feature is enabled. Enable the owning plugin and then its feature:

nullkomma.quarto = {
  enable = true;
  brand.enable = true;
};
# Or, for an R package:
nullkomma.r = {
  enable = true;
  pkgdown.brand.enable = true;
};

quarto.brand generates _nullkomma-brand.yml; list it in _quarto.yml's metadata-files. r.pkgdown.brand generates a pkgdown template package and selects it with template.package. Both use nullkomma.brand, but setting that shared data does not enable either plugin.

An optional defaults.nullkomma.footer record supplies footer facts to both builders:

meta:
  name: Example
  link:
    home: https://example.org
    imprint: https://example.org/imprint
    email: mailto:[email protected]
defaults:
  nullkomma:
    footer:
      tagline: A short description
      links:
        - text: Imprint
          link: imprint
        - text: Privacy
          privacy: true
      social:
        - text: Email
          link: email
          icon: envelope

Each link names an entry of meta.link; privacy: true links to your privacy policy, if there is one. icon is a Bootstrap Icon name. Write plain text only: Markdown or HTML fails the build.

Privacy policy

Plugins that process visitors' data add a section to nullkomma.privacy.sections (cloudflare adds hosting and analytics); so can a brand or your project:

nullkomma.privacy.sections.booking = {
  order = 500; # cloudflare is 200
  markdown = {
    en = ./booking.en.md;
    de = ./booking.de.md;
  };
};

quarto renders them, by order, into privacy.qmd (nullkomma.quarto.privacy.page), one tab per nullkomma.privacy.languages; pkgdown renders them to /privacy.html. Link the page from your footer, or set it to null to write your own. The sections are a starting point, not legal advice.

Technology-specific disclosures belong to the plugin using that technology, not the brand; the framework's nullkomma.privacy.policy is also usable without either site builder. For example, cloudflare adds its own sections on Cloudflare hosting and analytics.

apps

deploy

With cloudflare, publishes packages.site.

r-lock

With the r plugin, writes the pkg.lock the development shell needs.

shiny

With nullkomma.r.shiny.enable = true, serves the project's Shiny app at http://127.0.0.1:3838 using the pinned R environment.

taskRunners

default

up's runner for the tasks in perSystem.nullkomma.tasks.<name>, where you can add your own:

tasks          # list tasks
tasks check    # run one task
tasks all      # run all tasks not marked skip

Tasks can change files: R checks regenerate documentation and NAMESPACE. tasks all includes formatting and linting through treefmt. Set skip = true on custom tasks that should not run as part of tasks all. The pinned up runner does not forward optional arguments; invoke the listed task as a shell command for those, e.g. preview --port 4200 or check r-cmd-check.

Workflows: one command and graph

nullkomma.tasks is the workflow definition, not a second build recipe. Language plugins declare their defaults next to their implementation: R documentation, tests and pkgdown in modules/lang/r/workflow.nix, R CMD check in modules/lang/r/checks.nix, Shiny in modules/lang/r/shiny.nix, and Quarto in modules/lang/quarto/quarto.nix. Enabling the plugin supplies those tasks and processes; tasks lists them.

A task's after lists tasks whose steps run first, transitively and once each, wherever it runs: as a command, through tasks, in builds, in watch-<task> and in process trees. For example, r-test, r-coverage and shiny run after r-namespace; r-document also runs after r-namespace, while r-pkgdown and r-check run after r-document. Each therefore sees the generated files it needs, through the same command you'd run by hand. Outside process trees, steps run one after another and stop at the first failure.

perSystem.nullkomma.tasks.report = {
  description = "Render the report";
  after = [ "r-document" ];
  working_dir = ""; # checkout root, or the same root in the sandbox
  packages = [ pkgs.quarto ];
  command = ''
    quarto render report.qmd --output-dir "''${NULLKOMMA_OUTPUT:-report}"
  '';
  output = "report"; # adds packages.report; omit for development-only tasks
  watch.outputs = [ "/report" "/report/**" ]; # watch the tree, excluding this output
};

This adds report, tasks report, watch-report, pc-up-report and nix build .#report from one definition. Builds run the same executable and DAG on a writable nullkomma.src, with NULLKOMMA_ROOT pointing to that copy and NULLKOMMA_OUTPUT to the Nix output. working_dir is relative to that root; if omitted, tasks keep the caller's directory. sandboxArgs explicitly selects any necessary build-only behavior: R tests use --installed in the sandbox, versus pkgload locally. There is no separate build command.

Extend defaults using ordinary flake-parts merging: add a task, append after prerequisites, or use lib.mkBefore/lib.mkAfter for extra command lines and lib.mkForce to replace a command. Builds, checks and processes then use the changed task too. before is also supported. Up schedules the runner; nullkomma expands transitive prerequisites because the pinned runner otherwise runs only direct prerequisites for a named task.

Keeping generated files current

These watches refresh artifacts derived from the working tree, such as roxygen2's NAMESPACE and manuals. Files generated from Nix (editor settings, brand assets and Quarto extensions) are separate: the dev shell refreshes them on entry with nk-generate-files, and sandboxed builds use nullkomma.src. Tasks and process trees don't regenerate those files.

Choose the tool's own dependency knowledge before adding another watcher:

  • watch.native = true records that the command already watches and reloads, as Quarto's preview does. This adds pc-up-preview, not a redundant watch-preview.
  • watch.manifest = ./inputs.txt accepts a tool-produced, immutable file of newline-separated input paths, relative to the checkout root. It replaces the broad default; the tool must include every dependency. Watch containing directories for atomic saves and new files. Restart the watcher when the manifest changes.
  • Otherwise, watch = { }; watches the whole checkout, including new and Git-ignored inputs. watch.outputs declares output globs excluded throughout that process tree or standalone task's graph; watch.ignore adds local exclusions. Avoid narrow input allowlists unless the tool guarantees their completeness.

Native watching only covers dependencies the tool knows. Quarto, for example, does not discover arbitrary files read with io.open in Lua filters. Prefer declaring shared YAML through metadata-files and reading it from the filter's metadata; otherwise an external watch must cover those extra inputs. Do not add private inputs as site resources merely to watch them: resources are copied into the published site.

The fallback uses watchexec with changes queued while busy and Git/project ignore discovery disabled. Its built-in ignores still apply, as do .direnv/ and .nullkomma/; explicitly watched dependency stamps remain observable. Arbitrary external files, environment or network changes are not discovered. Generated outputs must be declared to avoid feedback loops. watch.paths can override the default; paths missing from the flake source are skipped. watch-<task> runs the task and its after steps initially and on input changes.

Every watched task automatically supplies a process tree containing its dependency graph. Use process = { }; for a service without any watcher. process also accepts native up process settings such as readiness_probe and shutdown; the workflow owns command and merges its dependency edges into depends_on.

Additional groups, perSystem.nullkomma.processTrees.<tree>, keep several tasks current in the background, as process-compose processes; pc-up-<tree> (also nix run .#processTrees.<system>.<tree>) starts them with a dashboard showing which currently fail:

perSystem.nullkomma.processTrees.docs = {
  description = "Keep the report current";
  tasks = [ "report" ]; # plus its `after` tasks
};
  • Tasks run in after order, independent ones in parallel.
  • Fallback-watched steps run again on input changes or successful prerequisite reruns; native-watched services handle changes themselves.
  • A failing task doesn't stop the tree. Only successful reruns notify dependents, so running dependents keep their last good state until it's fixed.
  • Steps that write files (e.g. roxygen2) run once per tree, for every process that needs them; state of a single R session (e.g. pkgload in shiny) stays in that process.
  • Successes are recorded as Git-ignored stamps in .nullkomma/processes/<tree>/, while each run uses its own process-compose socket in the temporary directory.

Trees don't start with the dev shell (#217); run pc-up-<tree> in a spare terminal.

Files in your repository

nullkomma keeps files in your repository to a minimum; features live in Nix. Some tools still need real files (Git needs .gitignore, GitHub needs workflows, editors need settings). Each is one of two kinds:

Source of truth (committed, yours to edit) Generated from Nix (Git-ignored, don't edit)
nix flake init --template flake.nix, .envrc (not possible)
nk-write-files .gitignore, GitHub workflows, AGENTS.md sections
nk-generate-files .vscode/*.json, .nvim.lua, .agents/skills/*, Quarto brand and extensions

Templates can't take parameters, so anything that depends on your configuration comes from nk-write-files or nk-generate-files.

Source-of-truth files

nk-write-files writes these once (nullkomma.files."<path>".mode = "source", the default); then they are yours to edit and commit. When nullkomma later changes a file, nk-write-files updates it only if you haven't edited it. Otherwise it keeps your version, saves an incoming copy in Git's metadata directory and fails, printing the copy's path and pointing you to the nullkomma-resolve-file-conflicts skill. After resolving, nk-write-files --accept <path> marks the file as up to date.

A file can instead hold marked sections inside your own text (e.g. R's .Rbuildignore rules): nk-write-files replaces only those, wherever you moved them. Once nullkomma no longer declares a file (say, you disabled its plugin), nk-write-files strips its sections, deletes it if unedited, or keeps your edited version and says so.

Generated files

nk-generate-files overwrites these (mode = "generated") when they differ from Nix, starts them with a "do not edit" comment, and the git plugin ignores them. They follow your Nix configuration, not your working tree: entering the dev shell runs nk-generate-files if they changed, while tasks such as preview never do. direnv re-enters when flake.nix or flake.lock change (say, after update); if local Nix modules declare files too, add e.g. watch_dir nix to your .envrc. To change one, change the Nix options. Directories (directory = true) are copied whole, without the comment. nk-generate-files refuses to overwrite a file tracked by Git, and removes files it generated before that are no longer declared.

Resolving conflicts

Coding agents can resolve conflicts with the nullkomma-resolve-file-conflicts skill, which the agents plugin writes into your project.

Plugin details

What plugins write into your repository (source-of-truth or generated) and how to configure them, where the table doesn't say it all. Linter settings are under Customizing linting.

nix

Ignores result in .gitignore. Unit tests for your flake's Nix code go in perSystem.nullkomma.nix.unit.tests; see tests.

agents

Instructs coding agents, through two kinds of files:

  • An agents section in AGENTS.md, written by nk-write-files and committed: run commands in the dev shell, and run tasks all before reporting a change as done. The rest of AGENTS.md is yours.
  • Agent Skills in .agents/skills/<name>, generated from Nix, so they always match your flake.lock. Plugins and you add them with the top-level nullkomma.agents.skills.<name>, a directory with a SKILL.md; nullkomma's own are named nullkomma-*, for example nullkomma-upgrade from the nix plugin.

Projects that don't use nullkomma yet can install this repository as an Agent Plugin with the adopt-nullkomma skill, for example with /plugin install https://github.com/dataheld/nullkomma in Copilot.

github

Writes .github/workflows/push.yml, a stub calling nullkomma's reusable CI workflow, which runs nix flake check on each supported system. After successful checks, pushes to the default branch and tags publish to FlakeHub: Flake visibility always matches the repository: public repositories publish public, listed flakes; private repositories publish private flakes. There is no visibility override. Forks do not publish. Private flakes require a FlakeHub paid plan. Remove any existing nullkomma.github.ci.inputs settings and with.visibility workflow arguments. nullkomma.github.ci.secrets passes repository secrets.

git

Writes .gitignore from the top-level nullkomma.gitignore lines that plugins and you add, plus every generated file.

direnv

Writes .envrc (use flake), which loads the development shell on entering the directory, and ignores .direnv/.

vscode and neovim

Plugins declare language servers once, editor-neutrally (perSystem.nullkomma.editor). The vscode and neovim plugins render them, with recommended extensions, into generated, Git-ignored .vscode/settings.json, .vscode/extensions.json and .nvim.lua, so nobody commits editor config. Neovim reads .nvim.lua only with :set exrc. Its :Format command formats the current buffer through treefmt.

Installing editors and extensions, themes and keybindings belong to your user environment, e.g. lap, which sets up the same two editors.

quarto

Opt-in quarto renders the project into packages.site; preview serves it locally and reloads on edits. It ignores .quarto/, _site/ and Quarto's generated .luarc.json. Shared Quarto extensions, e.g. from a brand flake, go in nullkomma.quarto.extensions.<name>, generated into _extensions/<name>. The privacy policy is generated into privacy.qmd.

For a documentation site, enable nullkomma.quarto.enable = true and select nkdocs, a Quarto extension nullkomma generates, as the project type in your _quarto.yml:

project:
  type: nkdocs
website:
  title: My project
  repo-url: https://github.com/example/project

nkdocs only switches on stock Quarto features and knows nothing of brands; configure everything else in _quarto.yml as usual, e.g. add pages to website.sidebar.contents. Set repo-url for repository actions.

The optional nullkomma.quarto.brand.enable feature styles websites, nkdocs or plain website, through generated _nullkomma-brand.yml: branded light/dark themes, navbar, footer and privacy link, on the standard Quarto theme rather than a separate Bootswatch theme. Mermaid diagrams automatically use the brand's Bootstrap colors in both modes, with distinct subgraph backgrounds and readable labels. Explicit Mermaid themes (e.g. format.html.mermaid.theme: forest) keep their own colors.

All generated paths are relative to nullkomma.quarto.root, including nested docs directories; they are the same files packages.site builds with. The build task renders the working tree into Quarto's local output directory.

cloudflare

Opt-in cloudflare serves whatever packages.site holds as Workers static assets, deployed with Terranix, including custom domains, _headers and _redirects; workers.dev is disabled. It adds hosting and analytics to the privacy policy and ignores .nullkomma/cloudflare/.

nullkomma.cloudflare = {
  enable = true;
  domain = "example.com";
  # name = "other-name"; # Override the repository-derived subdomain and Worker name.
  # worker = "existing-worker"; # Override only the Worker and Terraform state identity.
};

For a repository named foo, this publishes https://foo.example.com, including DNS and HTTPS certificates managed by Cloudflare. domain is the name of an existing active Cloudflare zone in your account.

Without name, deployment uses the repository name from GITHUB_REPOSITORY (GitHub Actions), or the local Git origin remote (HTTPS or SSH, without .git).

The plugin only deploys to subdomains. It leaves the parent domain (for example, example.com) and redirects alone. To serve www.example.com, set domain = "example.com" and name = "www".

Cloudflare creates the subdomain's DNS record and HTTPS certificate as part of deployment; no Zone ID or separate DNS setup is needed.

If Cloudflare reports an existing CNAME DNS record at an address you want to use, remove that record only if it no longer serves another site, then deploy again.

The old domains option is no longer accepted because it also allowed deploying to the parent domain. Existing users must review their Terraform state migration before switching to domain and name; do not simply remove the old setting and apply, as the custom-domain resource address has changed.

Terraform state remains keyed as <worker>.tfstate in the shared R2 bucket. Existing explicit Worker names and state keys are unchanged. Pin name before renaming a repository to keep its inferred hostname and Worker/state identity; pin worker separately if it differs. Worker names must be unique within the account, and state keys within the bucket, including across repositories with the same name. Changing these settings does not automatically migrate state.

Locally, run terraform to plan changes (or terraform <command> [args]) and deploy to publish; in CI, run nix run .#deploy. Both need an existing private R2 bucket for Terraform state; in GitHub Actions:

env:
  CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
  CLOUDFLARE_STATE_BUCKET: ${{ vars.CLOUDFLARE_STATE_BUCKET }}
  # Account API token: Workers Scripts Edit
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
  # R2 API token: Object Read & Write on the state bucket only
  CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
  CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}

The plugin maps the R2 credentials internally to the AWS_* environment names required by OpenTofu's S3-compatible backend; no AWS account is involved. Use the same Cloudflare environment names for local deployment.

One-time setup for this repository:

  1. Allow this repo access to the existing CLOUDFLARE_API_TOKEN and both CLOUDFLARE_R2_* organization secrets.
  2. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_STATE_BUCKET Actions variables to the existing account and private state bucket.
  3. Ensure the existing API token has Account / Workers Scripts / Edit; Custom Domains need no separate DNS token. Keep the R2 token scoped to Object Read & Write on the state bucket.

The parent zone must already be active in that account. This repo deploys assets and DNS only after successful CI on pushes to main, never from PRs or tags.

r

  1. Add nullkomma.r.enable = true; to flake.nix; for a package in a subdirectory, also nullkomma.r.root = ./pkg;.
  2. Run nix run .#r-lock: the development shell needs the pkg.lock it writes.
  3. Run nk-write-files and commit the changes; run nk-generate-files for editor configuration.

Dependencies

Declare dependencies in DESCRIPTION; pak locks them in pkg.lock. Refresh the lock after changing dependencies or updating Nix:

r-lock
git add DESCRIPTION pkg.lock

Nix takes package names from pkg.lock, but versions and builds from the nixpkgs-r input (which follows Nixpkgs; override inputs.nullkomma.inputs.nixpkgs-r for newer R). r-lock resolves against the PPM snapshot of the same date (nullkomma.r.snapshot); it writes the lockfile, not an installed library, and requires Nix. Outside Nix, R's non-Nix collaboration feature installs from that PPM snapshot.

Depends, Imports, LinkingTo, Suggests and all Config/Needs/* fields are locked, e.g. Config/Needs/development: devtools; override nullkomma.r.fields for a runtime-only lockfile. The development environment holds exactly these, plus pkgload, pak, languageserver, httpgd, later, rstudioapi and the tools of enabled workflows; nullkomma.r.extraPackages adds more. So a package with three dependencies gets those three, one with Shiny gets Shiny, and system libraries come with the packages that need them.

Note

Packages from Remotes, or missing from nixpkgs-r, build by default from their lockfile-pinned sources against nixpkgs' versions of their dependencies. This broadens the build surface beyond packages provided by nixpkgs-r. Set nullkomma.r.allowOffNixpkgs = false to require packages from nixpkgs-r only. Git sources use the locked commit; URL sources and CRAN packages missing from nixpkgs-r need nullkomma.r.hashes.<package> from nix store prefetch-file <url>. Add missing system libraries with perSystem.nullkomma.r.buildInputs.<package>. GitLab and local references are unsupported; use git:: for GitLab.

Workflows

tasks describes the available workflows and lists the checks. Disable optional checks and site builds with nullkomma.r.{check,testthat,pkgdown}.enable. r-test, r-coverage and shiny regenerate NAMESPACE first. r-pkgdown and r-check regenerate documentation and NAMESPACE first. pc-up-r-pkg keeps documentation, tests, the pkgdown site and R CMD check current while you work, and pc-up-r-shiny (with the Shiny app) also serves the app, reloading it in the browser whenever NAMESPACE is regenerated (see Keeping generated files current). Generated man/*.Rd files are Git-ignored, but not man/ itself; commit NAMESPACE. The fallback broadly watches the checkout through r-namespace and r-document; downstream R steps follow their successful reruns rather than racing roxygen2. Known outputs are excluded: NAMESPACE, man/*.Rd, compiler products, coverage and the pkgdown destination. This follows the plugin's convention that man/*.Rd are generated; for handwritten manuals, explicitly add their paths to r-document.watch.paths (explicit file watches override output ignores). Files such as man/figures/ remain inputs. Declare any extra outputs written by custom roclets or project code with watch.outputs. nullkomma.r.check.errorOn sets the failing severity (default "note"). The r plugin keeps its package-build exclusions in a marked section of .Rbuildignore, and ignores R build outputs in .gitignore.

check r-testthat is quick: it loads the source with pkgload instead of installing it. nix flake check and CI run check r-testthat --installed, which tests an installed copy, as R CMD check does. Config/testthat/parallel: true in DESCRIPTION runs tests in parallel.

Locking chromote (e.g. via shinytest2) adds Chromium on Linux (perSystem.nullkomma.r.browser); on macOS, which Nixpkgs' Chromium doesn't support, chromote finds your Chrome.

pkgdown website

packages.site builds the pkgdown site in the sandbox, also in nix flake check; r-pkgdown builds it locally, and r-pkgdown-preview also opens it. Cloudflare can publish it.

  • A patch skips pkgdown's online CRAN/Bioconductor lookup; add those links with home.links.
  • Bootstrap 5's JavaScript dependencies are pinned in nullkomma. Other remote templates, web fonts and network-dependent articles must be local for the sandbox.
  • _pkgdown.yml overrides template packages, e.g. R's branding feature's, which nullkomma.r.pkgdown.libraries provides; nullkomma.r.pkgdown.config is merged over both.
  • nullkomma.r.pkgdown.config.destination defaults to "docs" and overrides a YAML destination, so local builds and watcher exclusions agree. Set custom destinations here rather than in _pkgdown.yml, as a normalized package-relative directory (no leading slash, . or .. components).
  • nullkomma.r.pkgdown.assets is a directory generated into Git-ignored pkgdown/assets/, which pkgdown copies to the site root; leave it null to manage that directory yourself.
  • The privacy policy is published at /privacy.html; set nullkomma.r.pkgdown.privacy.page = null to write your own.

Shiny app

Export a zero-argument function returning a shiny.appobj, e.g. app <- function() shiny::shinyApp(ui, server), declare shiny in DESCRIPTION, refresh pkg.lock and enable the launcher:

nullkomma.r.shiny = {
  enable = true;
  function = "app"; # default
  host = "0.0.0.0"; # default "127.0.0.1"; port defaults to 3838
};

nix run .#shiny installs your package from the flake's source, calls the function and serves the app. The shiny task instead loads the working-tree package with pkgload, without installing it. Its runtime is R, Shiny and your package's Depends/Imports closure: no Suggests, Config/Needs/*, extraPackages or pkgload. Ship assets in inst/ (find them with system.file()) and write data outside the installed package.

Test it by passing the function to shinytest2: shinytest2::AppDriver$new(app). Under check r-testthat, the app process loads your source with pkgload, so there's nothing to install and no stale installed copy; with --installed, it uses the installed package.

Formatting and tools

Air formats R and Jarl lints and fixes it first, configured through Nix (disable with nullkomma.r.jarl.enable = false). R projects allow HTML and long lines in Markdown (MD033, MD013), keep typos from "correcting" the author role fnd in DESCRIPTION, and skip testthat snapshots and docs/.

The tools follow current r-lib practice: rcmdcheck, roxygen2, pkgdown and covr. Jarl replaces lintr: it needs no package load and fixes lints before Air formats. Coverage stays local, without an upload service. Everything runs on one locked R version; add a CI matrix if you need Windows or other R releases.

R development without Nix

For collaborators without Nix, enable nullkomma.r.nonNix.enable = true alongside nullkomma.r.enable = true, run nk-write-files, and commit .Renviron, .Rprofile, .R/install.R, .Rbuildignore and the R development without Nix section added to your README. These are source-of-truth files. The startup profile only acts in native R sessions, installing from the same PPM snapshot (nullkomma.r.snapshot, nullkomma.r.ppmUrl); it leaves Nix shells, builds and tasks alone.

Design

Nullkomma always provides two self-documenting entrypoints:

  1. The consumer's flake outputs. These are already self-documenting.
  2. tasks expose a selection of useful builds and checks. This does not mean that tasks aliases every subcommand and flag. As a heuristic, if some command is run to produce a flake output, it is also exposed as a tasks. In addition, tasks include important development-only workflows, especially previews.

As much as possible, the flake outputs and tasks reuse implementation, to make interactive development work in the devShell as close as possible to the flake outputs. Exceptions include:

  • Tasks run tools directly on the working tree, rather than recursively invoking Nix builds for speed and simplicity.
  • Tasks run on the uncommitted working tree for faster turnaround.
  • Sandboxed builds and checks use nullkomma.src, a writable copy of the flake's source with Nix-generated files.
  • R tests use pkgload locally and install the package in the sandbox; Shiny's published app also uses an installed package without development dependencies.

The non-Nix R compatibility layer necessarily documents its own bootstrap.

The Plugins table maps nullkomma's outputs onto yours: each row is one of nullkomma's flakeModules.<name>, each column an output of your flake. Every output is fed by framework options (in perSystem) that plugins fill in, and so can you:

Your output Options Task
devShells.default nullkomma.devShell.*
formatter, checks.treefmt treefmt.programs format
checks.<name> nullkomma.checks.<name> check
packages.site packages.site build-site / r-pkgdown
apps.<name> apps.<name> plugin-specific
taskRunners nullkomma.tasks.<name> tasks

Formatting and linting are treefmt's job: plugins add treefmt.programs, not their own runner. Two more framework options act on your working tree rather than on outputs: nullkomma.files.<path> (and the top-level nullkomma.gitignore), written by nk-write-files and nk-generate-files; and nullkomma.editor, whose language servers join the dev shell and which vscode and neovim render into files.

Independent plugins do not read each other's options. Values shared between plugins are framework options that any plugin may set or read. For example, r relaxes Markdown rules through nullkomma.lint.markdown, which markdown hands to rumdl; neither knows about the other.

Features within a plugin may use its options directly. Separate modules contribute to the same plugins.r or plugins.quarto: non-Nix collaboration, pkgdown branding and Quarto branding are isolated implementations, not peer plugins. Their options are always available when their owner is imported; their effects require both the owner and feature to be enabled. pkgdown branding also requires pkgdown to be enabled. nkdocs is a bundled Quarto extension selected by native project.type: nkdocs, not a second Nix switch.

Because plugins only meet through framework options, one could live in its own flake, e.g. a nullkomma-r exporting flakeModules.r against flakeModules.framework, keeping R-only inputs out of other projects' lockfiles.

Development

Before building something, check flake.parts for an existing module; see AGENTS.md. Inputs only nullkomma's own development needs go in dev/flake.nix, so they stay out of projects' flake.lock.

Documentation website

nullkomma's own website is this README, built as a quarto nkdocs site into packages.site styled by Quarto's branding feature with Max Held's brand and privacy policy. tasks lists how to preview and check it.

Testing nullkomma

nullkomma is tested against small example projects ("fixtures") in tests/fixtures, which use nullkomma the way a real project would.

  • Unit tests (nix-unit) check the fixtures' evaluated configuration, e.g. that every plugin can be switched off. They fetch dev-only inputs through the nix plugin's network-enabled fixed-output derivation.
  • Docs test: options-doc renders every option as flake.parts would, so each needs a description.
  • Legal tests: site-legal and r-pkgdown-legal build this site and a pkgdown fixture with the real brand, and check that every page links an imprint and the privacy policy, which covers GDPR Art. 13 in English and German and every footer email address.
  • Build tests run the real tools on the fixtures in nix flake check; Nix and Markdown files listed in rejects must fail.
  • End-to-end test: check-fixtures runs nix flake check on a copy of a fixture against this checkout, as a user would, the only test of how real projects fetch nullkomma. CI runs it on every push.
  • Without Nix: test-r-non-nix builds the r-non-nix fixture in Docker (Ubuntu 24.04, linux/amd64, no Nix), runs the documented R examples and builds the package; extra arguments go to docker build. CI runs it on manual dispatch.

New plugins should add a valid and a deliberately broken fixture, and check that the broken one fails with the real tool's error, not just any error.

refresh-vendored refreshes nullkomma's vendored third-party assets and dependency manifests. Currently it runs refresh-vendored-pkgdown, which regenerates modules/lang/r/_vendored-pkgdown.json from the pinned pkgdown package's own metadata. Nix fetches the assets using that manifest, so sandboxed site builds need no CDN access; the asset files are not committed to this repository. checks.vendored-pkgdown detects a stale manifest after a pkgdown upgrade.

Add another refresh task through the existing task DAG, not a separate registry:

perSystem.nullkomma.tasks.refresh-vendored-example = {
  description = "Refresh vendored Example assets";
  before = [ "refresh-vendored" ];
  working_dir = ".";
  command = "...";
};

These are nullkomma's own maintenance tasks, not tasks added to consumer projects.

Footnotes

  1. nix fmt and the format task fix what they can; checks.<system>.treefmt fails nix flake check (and CI) on any remaining formatting or lint issue. ↩

  2. packages are what Nix builds (nix build), apps what it runs (nix run): a program, e.g. a deploy script or a Shiny server, usually backed by a package. ↩

  3. up process trees, each also the pc-up-<tree> task; see Keeping generated files current. ↩

  4. The flake-checker task checks flake.lock for outdated or non-upstream Nixpkgs inputs. ↩

  5. Tests in test/ (nullkomma.shell.bats.testDir), once that directory exists; add the tools they call with perSystem.nullkomma.shell.bats.packages. *.bats files are formatted and linted like other shell scripts. ↩

  6. terranix also exports it as packages.deploy, whose .init, .plan, .destroy, .terraform and .config attributes nix run and nix build reach, e.g. nix run .#deploy.plan. ↩

About

Opinionated πŸ€“, batteries-included πŸ”‹, extra-DRY 🀌 Nix boilerplate.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages