No description
  • Nix 98.7%
  • Python 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JMARyA 540e6d2aaf
test(lib/files): pin the nix-files contract moira's flake page reads
moira draws nix-files from mkFiles' output (moira 06bf63f): a tree is any
output whose root holds `.nix-files`, its check is `<name>-check`, drift
lines carry the `missing:`/`changed:`/`not executable:`/`executable:`/
`orphaned:` prefixes, and one sentence separates drift from a check that
failed for another reason. Those are interface now, so they are listed in
files.nix and a constructor test fails if any of them changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:16:48 +02:00
envs feat(envs): toolsets for auditing, scanning and linting 2026-10-02 19:05:20 +02:00
lib test(lib/files): pin the nix-files contract moira's flake page reads 2026-10-11 08:16:48 +02:00
modules feat(vcs/forge): pr_upsert and pr_resolve 2026-10-11 07:53:00 +02:00
test_threads test(live): audit and drift state, replayed night by night; live-terraform applies 2026-10-11 07:27:15 +02:00
threads ci: build this repository with its own constructors 2026-10-02 19:09:22 +02:00
.gitignore fix(threads)!: five silent failures in the constructor layer 2026-10-01 23:40:42 +02:00
flake-module.nix feat(lib): Nix-managed repository files — mkFiles, mkProject, nixFiles 2026-10-11 07:53:00 +02:00
flake.lock init: standard library foundation 2026-04-19 20:37:56 +02:00
flake.nix feat(lib): Nix-managed repository files — mkFiles, mkProject, nixFiles 2026-10-11 07:53:00 +02:00
README.md feat(lib): Nix-managed repository files — mkFiles, mkProject, nixFiles 2026-10-11 07:53:00 +02:00

moira-modules

Standard library for moira — reusable environments, typed modules, and reference threads.


What's here

Directory Contents
envs/ Nix shell environments for common toolchains
modules/ Typed, reusable step implementations
threads/ Ready-to-use pipeline definitions
lib/threads/ Thread constructors — functions producing a thread
lib/detect/ Project detection — turns a repo into facts, then threads
lib/py/ Shared Python prelude concatenated into module impls
lib/options.nix The precedence engine every constructor runs on
lib/webhook.nix The HTTP route every endpoint shape shares
lib/terraform.nix The init step both terraform constructors share
lib/files.nix mkFiles: a tree of generated files, its writer and its check
lib/project.nix, lib/project/ mkProject and its presets: common files from common options
flake-module.nix flake-parts module wiring mkProject into packages/apps/checks
lib/checks/ Eval-level schema check over modules/
lib/tests/ Eval tests for the constructors

threads/ and lib/threads/ are different things. A file in threads/ is a
concrete thread, exported as moiraPipelines and runnable as-is. A file in
lib/threads/ is a function you apply to get a thread, exported under lib.
Constructors are deliberately kept out of moiraPipelines, since moira runs
what it finds there and a function is not something it can run.


Import

# flake.nix
inputs.moira-modules.url = "git+https://git.hydrar.de/jmarya/moira-modules";
inputs.moira-modules.inputs.nixpkgs.follows = "nixpkgs";

Inside your outputs, bind moiraModules and moiraEnvironments per system:

outputs = { self, nixpkgs, moira-modules, ... }:
  flake-utils.lib.eachDefaultSystem (system: {
    moiraPipelines = {
      ci = moira-modules.moiraPipelines.${system}.ci;      # use a reference thread
    };
  });

Or reference them directly in thread files:

# threads/deploy.nix
{ moiraModules, moiraEnvironments, ... }:
{
  name = "deploy";
  trigger.on_push.branches = [ "main" ];
  env = moiraEnvironments.rust;

  steps = [
    { name = "test"; run = "cargo test --workspace"; }
    { name = "build"; run = "cargo build --release"; depends_on = [ "test" ]; }
    {
      name = "notify";
      use  = moiraModules."http/http-request";
      "with" = {
        method   = "POST";
        url      = "https://hooks.example.com/deploy";
        body_json = { sha = "${{ git.sha }}"; status = "ok"; };
      };
      depends_on = [ "build" ];
    }
  ];
}

Thread constructors

Each constructor takes an options attrset and returns a thread. Every option
has a default; call-site values win.

Pass src and a constructor reads the project instead of assuming — see
Derived threads.

Constructor Produces
rustCi Parallel fmt/clippy gates, then test
nodeCi install, then only the scripts the project actually defines
goCi Parallel fmt/vet gates, then test
pythonCi install, then the lint gates the project configures
fromProject Reads a repo and returns one thread per project it finds
container Push a flake-built image via container/skopeo-push
containerManifest Merge arch-suffixed tags into one multi-arch manifest list
s3Site Mirror a flake-built directory to an S3 bucket
endpoint Serve a module, a list of steps, or a whole thread over HTTP — see Endpoints
endpoints A set of endpoints as one API, with a shared prefix and auth
audit A scheduled vulnerability scan, one scanner per language; with stateBucket, red only on a new advisory
relock Repair flake.lock on a dependency bot's pull request
nixFiles Keep Nix-generated repository files committed — a pull request on drift by default; see Nix-managed files
flakeStaleness Weekly check that each tracked branch head is still moving
secretScan TruffleHog over the tree on every push, over history nightly
asIssue Wrap any thread so its findings become a forge issue that closes itself
nixCi nixfmt, statix, deadnix and a flake.lock gate — see Nix gates
terraformPlan terraform plan on a pull request, posted to the pull request
terraformApply terraform apply of a reviewed plan file, behind an approval
terraformDrift Nightly terraform plan against the real thing, red when they differ — or, with stateBucket, when the difference changes
release Cut a forge release when a tag is pushed, with notes from a changelog
nixosDeploy Switch a NixOS host to this commit's configuration, rolling back if it fails — see NixOS deploys
notify Append an always-run ntfy status step to any thread
# flake.nix
outputs = { self, nixpkgs, flake-utils, moira-modules, ... }:
  flake-utils.lib.eachDefaultSystem (system: {
    moiraPipelines =
      let t = moira-modules.lib.${system};
      in {
        ci = t.rustCi { };

        container = t.container {
          archive = "\${{ flake.packages.containerImage }}";
          image   = "git.example.com/org/app";
        };

        docs = t.s3Site {
          source   = "\${{ flake.packages.docs }}/";
          bucket   = "app-docs";
          endpoint = "https://s3.example.com";
        };
      };
  });

needs_flake is derived from the ${{ flake.… }} reference in archive /
source, so it does not have to be repeated. Pass needsFlake explicitly for
references assembled at runtime, which the scanner cannot see.

Site defaults

withDefaults bakes in per-site values once rather than repeating them in
every repo. It composes — the result carries its own withDefaults.

t = moira-modules.lib.${system}.withDefaults {
  s3Site.endpoint = "https://s3.example.com";
  s3Site.region   = "eu-central-1";
};

# endpoint and region are already set
docs = t.s3Site { source = "…"; bucket = "app-docs"; };

It returns a whole library, not only the constructors, so fromProject,
explain and withDefaults itself all carry the defaults. That matters most for
a derived thread: the docs plan can tell that a repository publishes docs, but
only you know which endpoint and region your buckets live at.

moiraPipelines = t.fromProject { src = self; docs.bucket = "app-docs"; };

Multi-arch images

arches turns container into a thread-level matrix: one independent child
run per architecture, each pushing an arch-suffixed tag and routed by the
agent's system label. containerManifest then merges those tags via
on_workflow.

container = t.container {
  archive = "\${{ flake.packages.containerImage }}";
  image   = "git.example.com/org/app";
  arches  = [ "x86_64-linux" "aarch64-linux" ];
};

container-manifest = t.containerManifest {
  onWorkflow = "container";   # the default; { name; status; } for a filter
  image      = "git.example.com/org/app";
};

This needs one registered agent per architecture. The merge is a separate
thread because a thread-level matrix produces independent runs — no step
inside container observes all of them.


Nix gates

Four independent gates for a repository written in Nix, run in parallel:

moiraPipelines.nix = t.nixCi { };
Gate Runs
fmt nixfmt --check over every .nix file
lint statix, for the antipatterns nixfmt cannot see
dead deadnix, for the bindings nothing reads
lock flake.lock still satisfies flake.nix

There is deliberately no nix flake check step, for the same reason rustCi
has no build step. moiraFlake builds flake outputs on the same push, so a
repository that declares

moiraFlake.include = [ "checks.*" "packages.*" ];

gets every check as its own scheduled, cached, separately reported job — while a
step shelling out to nix flake check repeats that work serially, inside one
log, in a form nothing can reuse. The four gates above are the part that is
not a flake output and therefore has nowhere else to live.

One trap worth knowing, because it fails silently: moiraFlake is not
system-keyed.
The agent runs nix eval .#moiraFlake and deserialises the
result as a string, a list, or { include; exclude; }. A per-system attrset
matches none of those, and the agent returns "build nothing" on any parse
failure rather than reporting one — so inside flake-utils'
eachDefaultSystem, this attribute means nothing is built and nothing says so.
moira's own history contains that exact bug. Declare it outside the per-system
block:

outputs = { self, nixpkgs, flake-utils }:
  { moiraFlake.include = [ "checks.*" "packages.*" ]; }
  // flake-utils.lib.eachDefaultSystem (system: { … });

moiraPipelines is read per-system, so it belongs inside. The two are not the
same shape, which is the whole reason this is worth a paragraph.

The lock gate is the only one that runs nix at run time, so it is the only
reason the thread asks for sandbox_defaults.nix_daemon. That is moira's
documented escape hatch and an agent may refuse it — a build a step performs
itself is not scheduled, not deduplicated and not observable in the run's flake
view — so the gate is explicit about the trade rather than quietly asking for a
daemon. lock = false if your agents do not grant one.

Unlike every other trait in this library, this one is not derived. Three of
the four gates are matters of style, and a thread that is red the moment it is
generated is a thread people delete rather than fix. A repository asking for
these has decided to be clean, which is not something a manifest states. The
flake trait therefore plans flake-staleness and stops there.

Nix-managed files

Files a repository commits but generates in Nix: renovate.json, cog.toml,
.editorconfig, CODEOWNERS, treefmt.toml, LICENSE, or anything else.
The flake-parts module is the short way in:

imports = [ moira-modules.flakeModules.default ];

perSystem = { pkgs, ... }: {
  moira.files.enable = true;
  moira.project = {
    bot.email = "moira@example.com";        # renovate's gitIgnoredAuthors, and so relock's commitEmail
    renovate.enable = true;
    renovate.settings.schedule = [ "before 6am on monday" ];
    cog.enable = true;
    editorconfig.enable = true;
    license = { enable = true; spdx = "MIT"; holder = "jmarya"; year = 2026; };
    treefmt = {
      enable = true;
      packages = [ pkgs.nixfmt ];            # generated files are formatted with this config
      settings.formatter.nixfmt = { command = "nixfmt"; includes = [ "*.nix" ]; };
    };
    files."docs/extra.md" = ./extra.md;      # anything a preset does not cover
  };
};

That gives the flake packages.files (the tree), apps.files (nix run .#files
writes it and lists what changed), and checks.files, which is red while the
committed copy differs. Under moiraFlake that check is a per-commit forge
status like any other, so it answers "in sync?" with no thread at all. Without
flake-parts: t.mkProject { modules = [ … ]; } returns the same files, and
t.mkFiles { files = { … }; } is the layer underneath with no presets.

moira.project is an ordinary module-system module. Each preset has typed
options for the values something else depends on (bot.email, cog.tagPrefix,
cog.changelog) and a freeform settings for everything else, merged across
modules. A preset's defaults are mkDefault, so a plain definition wins, and a
raw files."<path>" replaces a preset's file outright.

The repair is the nixFiles thread:

moiraPipelines.files = t.nixFiles { };   # tree defaults to ${{ flake.packages.files }}
mode On drift
pr (default) On a push to main: commit to moira/nix-files, force-push, open or update one pull request (vcs/forge pr_upsert). When main is clean again, the stale pull request is closed (pr_resolve). Green either way.
check Red, on pushes and pull requests. Writes nothing. For a repository without checks.files.
push Commit onto a bot's pull request branch, relock-style. Needs commitEmail.

The sync commit is deterministic: it carries the base commit's dates and no run
id. Pushes to main that don't change the generated output re-push the same
commit, so the pull request is left alone. Only managed paths are diffed or
committed, and an entry removed from files is deleted rather than left
behind. pr mode needs a FORGE_TOKEN secret with write:repository.

NixOS deploys

Switch a host to the configuration this commit built, verify it, and switch it
back if that fails:

moiraPipelines.deploy-web = t.nixosDeploy {
  src           = self;          # optional: catches a misspelt configuration
  configuration = "web";
  host          = "web.example.com";
  knownHosts    = "web.example.com ssh-ed25519 AAAA…";
  healthCheck   = "curl -fsS localhost:8080/healthz";
};
Step Does
switch Nothing, if /run/current-system already is the build; otherwise realise it from the host's substituters, then switch-to-configuration under systemd-run
health healthCheck on the host, retried for healthTimeout seconds — only when one is given
rollback On a failed thread, switch back to what was running before this run switched

The host is the state. What is running is /run/current-system, and what to
roll back to is what that was a moment before the switch — not the previous
generation, and not anything a thread remembered in a bucket, both of which are
wrong the first time somebody runs nixos-rebuild by hand. So a push that
changed a README restarts nothing, and nothing here needs state/kv.

The rollback acts only on its own run. Before switching, the run writes what
was running to /run/moira-deploy/<run id>, and removes it once the deploy is
verified. A run that failed before switching finds no marker and rolls back
nothing, and no run can revert to a configuration an earlier one left behind.

It pulls rather than pushes. Realising an output path only substitutes, so a
host that does not use moira's cache (moira.nixosModules.moira-cache) fails
the switch step with nothing changed, rather than building on the host.

It refuses a host key it has not been given. This connects as root and runs
what it is told; ssh-keyscan prints the line for knownHosts, to check against
the host before trusting it. insecureHostKey = true is the explicit way out.

Concurrency is queue, never cancel — interrupting a running switch is how a
host ends up half-activated. A fleet is one thread per host:

moiraPipelines = lib.mapAttrs' (cfg: host: lib.nameValuePair "deploy-${cfg}"
  (t.nixosDeploy { configuration = cfg; inherit host; knownHosts = keys.${cfg}; }))
  { web = "web.example.com"; db = "db.example.com"; };

Derived threads

A constructor given src reads the project's own manifests at eval time and
fills in its options from what it finds, instead of the caller restating what
Cargo.toml already says two directories away.

ci = t.rustCi { src = self; };   # workspace? vendored? sqlx? — detected
ci = t.rustCi { };               # exactly as before; detection never runs

fromProject goes one step further and works out which constructors the repo
needs at all:

moiraPipelines = t.fromProject { src = self; };   # the whole CI config

It always returns an attrset of threads — { ci = …; ci-web = …; } — because
that is the type moiraPipelines wants. One thread per detected project, each
with a trigger.on_push.paths filter covering the project and everything it
depends on
, so a push touching only web/ does not schedule the backend thread
at all. See Path filters for why the second half matters.

Pass self, not ./.: the latter copies the whole worktree into the store at
eval time.

Since the result is a plain attrset, threads that are not derived from a
manifest compose with it directly:

moiraPipelines = t.fromProject { src = self; } // {
  container = t.container {
    archive = "\${{ flake.packages.containerImage }}";
    image   = "git.example.com/org/app";
  };
};

With src = self that call is derived too. Whether packages.containerImage
exists is a question about the flake's evaluated outputs, not about anything it
builds, so it needs no import-from-derivation: a package named containerImage,
dockerImage or ociImage on any Linux system plans a container thread
(container-<name> when there are several), skipped with a warning until it is
given the one thing no file says — container.image = "git.example.com/org/app";.
Only the names are read; nothing is built, and a system whose package set throws
costs the trait its answer for that system rather than the evaluation.

Cross-cutting threads

A directory claims at most one project, but a project may want more than one
thread. A documentation site is the clear case: it is not a language, it owns no
directory, it sits inside a project that is already Rust or Python — and a
repository with a zensical.toml is saying something perfectly definite about
what it publishes.

So a trait can plan, not only contribute facts:

moiraPipelines = t.fromProject { src = self; docs.bucket = "app-docs"; };
# => { ci = …; docs = …; }

The docs thread is an s3Site with needs_flake resolved from
${{ flake.packages.docs }} and a push filter of the docs config plus docs/**.

What it does not guess is the bucket. That appears in no manifest, and a guess
would publish a site somewhere nobody named, so it is requiresd — and the two
failure modes are deliberately not treated the same:

  • a project's own CI that cannot be built is an error. A project with no
    CI thread is the silent-green outcome this whole layer exists to prevent.
  • a cross-cutting thread whose requirement is unmet is a warning, and the
    thread is left out. A repository that happens to contain mkdocs.yml must still
    evaluate:
evaluation warning: moira fromProject: skipping the 'docs' thread that trait:docs
  planned for .. It needs `bucket`, which no manifest can supply. Pass it to
  enable the thread:

    fromProject { src = self; docs.bucket = …; };

Releases

The second trait that plans, and a different shape of the same argument. A
repository with a CHANGELOG.md — or a cliff.toml, or a release-plz.toml —
is saying in the most deliberate way a repository can that it publishes
versioned releases. The thread that implies is the one people hand-write most
often, because every piece of it was already here and only the wiring was not.

moiraPipelines = t.fromProject { src = self; };
# => { ci = …; release = …; }

The release thread fires on v* tags and nothing else, takes its body from
the changelog section matching the tag, and publishes through vcs/forge with
tag and target left to default from the run.

What it will not read is git history. src is a flake's view of the worktree,
which contains no .git, so "does this repository have tags?" is not a question
detection can ask — and a good thing, since the answer would be about the past
while the thread is about the next tag. Nor is a version field evidence: every
manifest ever written has one, including those of things that are never
released.

For a subproject the tag pattern is requiresd, like the docs bucket and for
the same reason. v* is the convention for a repository's own tags and says
nothing about a crate in a monorepo, which releases on something like web-v*;
a guess there is a release thread that silently never fires.

evaluation warning: moira fromProject: skipping the 'release-web' thread that
  trait:release planned for web. It needs `tags`, which no manifest can supply.
  Pass it to enable the thread:

    fromProject { src = self; release-web.tags = …; };

Artifacts are not inferred either — knowing that packages.app exists needs
import-from-derivation, the same reason container chaining is not inferred. Name
them and needs_flake follows from the reference:

moiraPipelines = t.fromProject {
  src = self;
  release.files = [ "${{ flake.packages.app }}/bin/app" ];
};

One rule came out of building this and is not release-specific, so it lives in
opts.triggerOf where every constructor gets it: a path filter on a tags-only
trigger is an error.
push_matches returns on the tag pattern before it reads
paths, so the pair is not a narrower filter but one that does nothing — while
reading in the definition exactly like one that does. fromProject derives a
path filter for every thread it plans in a subdirectory, which is right for CI
and wrong here, so the release plan clears it; this is what stops it coming back
by hand.

Every plan names a constructor you can call

The rule for the whole layer, and the reason the list of traits can grow without
the generated config becoming a thing nobody can adjust: a plan may only name
a constructor that exists as a function.
docs plans s3Site, release plans
release, audit plans audit, terraform plans terraformPlan and
terraformApply — all of them exported under lib, all of them callable by
hand with the same options.

So a derived thread and a written one are the same thread, and the three ways out
of a derived config are all the same size:

# take it, and change one option
moiraPipelines = t.fromProject { src = self; audit.schedule = "0 4 * * 1"; };

# decline it, and write that one yourself
moiraPipelines = t.fromProject { src = self; without = [ "audit" ]; } // {
  audit = t.audit { run = "cargo audit --ignore RUSTSEC-2024-0001"; };
};

# decline the lot, and keep the builders
moiraPipelines = {
  ci    = t.rustCi { src = self; };
  audit = t.audit { run = "cargo audit"; };
};

A plan naming a constructor this flake does not ship is an error that says so,
rather than a missing attribute naming neither the trait nor the thread.

Declining a thread

without refuses a planned thread by name, or by the plan that wanted it:

moiraPipelines = t.fromProject {
  src     = self;
  without = [ "trait:release" ];   # or [ "release" "docs-web" ]
};

This is the other half of warn-and-skip. Until it existed the only way to refuse
a thread was to withhold the option it requires, which works by accident: it
reads as an oversight, it warns on every eval, and it does nothing at all
against a thread that requires nothing — which an audit does not, and a
root-level release does not.

An entry matching nothing is an error. A misspelled opt-out would otherwise
be a thread that is still generated, which is this repository's own bug class
pointed at its newest feature:

error: moira fromProject: `without` names `audit-wbe`, which no plan produced.

       A misspelled opt-out leaves the thread being generated, so this is an error
       rather than a line that does nothing.

       Threads planned for this repository: ci, ci-web, audit-web
       Plans that wanted them: kind, trait:audit

explainProject reports a declined thread as declined, which is deliberately
distinct from skipped: one is an answer you gave, the other is a question
nobody answered.

Audits

A lockfile is a list of other people's code, pinned. The advisories against it
appear without this repository changing at all — which is why the thread is
scheduled rather than a gate, and why it is the one thread nobody writes until
the week they wish they had.

moiraPipelines = t.fromProject { src = self; };
# => { ci = …; audit = … }

One scanner per language, chosen by the project's kind rather than by whichever
lockfile the walk saw first — so a web/ directory with a package lock inside a
Rust repository gets npm's scanner, not cargo's:

Lockfile Scanner
Cargo.lock cargo audit
go.sum govulncheck ./...
pnpm-lock.yaml pnpm audit --audit-level high
yarn.lock yarn npm audit --severity high
package-lock.json npm audit --audit-level=high

Python is deliberately absent: pip-audit wants a requirements file or an
installed environment, and neither uv.lock nor poetry.lock is one without an
export step whose correct form depends on the tool. There is no command here
that would be right, and a guessed one is a thread that fails every night for a
reason that is not a vulnerability.

A finding fails the thread — that is the notification, and t.notify turns it
into a push message either way. The network is on, deliberately: these are the
steps whose whole purpose is to reach the internet, which is also why a vendored
repository's network = false does not apply to them.

A red run every night for the same advisory is how an audit stops being read.
Give it somewhere to remember, and it fails only on the night something new
appears:

audit = t.notify {
  topic  = "security";
  thread = t.audit { run = "cargo audit"; stateBucket = "moira-state"; };
};

What is remembered is the set of advisory IDs the scanner named (RUSTSEC-…,
GHSA-…, GO-…, CVE-…, PYSEC-…; findingPattern changes the regex), not
its output, which carries a database size that changes nightly. A scanner that
exits non-zero without naming anything fails the run rather than being stored
as "nothing found". Set it once for every audit, derived ones included, with
withDefaults { audit.stateBucket = "moira-state"; }.

Use this or asIssue, not both: an issue already reports each finding once,
and a second-night green run would close it with the advisory still there.

Locks

Three different questions get asked about one lockfile, and they have three
different answers, three different triggers and three different threads. Nothing
here is interchangeable with anything else here:

Question Thread Trigger
Does anything it pins have an advisory? audit nightly
Is it consistent with what flake.nix asks for? relock bot pull request
Are the branches it tracks still alive? flake-staleness weekly

The second and third are upstreamed from hand-written threads in an
infrastructure repository, which is where both failures were found the hard way.

relock. A dependency bot cannot recompute a narHash. Renovate bumps the
version inside the .tgz URL of a flake input, runs without Nix, and opens a
pull request whose flake cannot evaluate at all — so every check on it fails for
a reason that has nothing to do with the change. renovate.json next to a
flake.nix is a complete statement of that problem, so it is derived:

moiraPipelines = t.fromProject { src = self; };
# => { ci = …; relock = …; flake-staleness = … }

Three things that thread has to get right are invisible in its steps, and are
the reason it is worth being a constructor rather than a snippet to copy:

  • It declares no needs_flake, deliberately. moira only gates a run on
    flake evaluation when the thread asks for a flake output — so a thread without
    one still dispatches on a pull request whose flake is broken. That is the whole
    trick: this is the thread that has to run precisely when evaluation is
    impossible.
  • permissions.contents = "write". The credential a run clones with is
    read-only by default and this thread's entire output is a commit, so the
    declaration is part of the constructor rather than something a caller
    remembers.
  • policy = "queue", never cancel. A concurrency policy is scoped to the
    repository and the thread name, not to the pull request — so cancel would
    let activity on one bot branch supersede the relock of another.

One value it will not guess is the commit author. The bot's gitIgnoredAuthors
has to list it, or the bot sees its own branch as modified by a human and stops
updating it — a wrong address does not fail the thread, it quietly retires the
bot. But it is not unknowable either: gitIgnoredAuthors is renovate being
told which commits on its branches are not a person's, so the config that
creates the requirement also answers it, and detection reads it from there. A
config that names none asks:

evaluation warning: moira fromProject: skipping the 'relock' thread that
  trait:renovate planned for .. It needs `commitEmail`, which no manifest can
  supply. Pass it to enable the thread:

    fromProject { src = self; relock.commitEmail = …; };

The head-branch guard is a case in the step rather than a trigger field,
because on_pr filters by target branch and nothing in moira filters by the
branch a pull request comes from. That guard is also the whole reason a derived
thread with write access is defensible: renovate.json says a bot already pushes
here on renovate/*, and this touches flake.lock on exactly those branches
and nothing else. nix flake lock, not update, so one bumped dependency costs
one fetch instead of refetching the lot.

flake-staleness. The question is not "how old is our lock?" but "how old
is the branch head we are locked to?"
, and the difference is not academic. A
repository tracked nixos-25.11; the branch reached end of life and froze; the
lock was already sitting on its final commit, so every nix flake update was a
legitimate no-op. Two of a cluster's most critical packages quietly stopped
receiving patches — including CVE fixes — for two months, with nothing anywhere
reporting a problem. No audit catches that: nothing is vulnerable yet, and the
lock is not out of date.

INPUT                  BRANCH               HEAD AGE  STATUS
nixos-raspberrypi      main                      61d  (not a channel, informational)
nixpkgs                nixos-unstable             1d  ok
nixpkgs-stable         nixos-25.11              117d  DEAD — branch frozen, likely EOL

Only inputs that track a branch are checked — a rev or tag pin is a deliberate
choice and stays put on purpose — which is also what decides whether the thread
is planned at all: a lock of nothing but revs has no failure of this shape
available to it. Only channelPattern refs (nixos-* by default) fail the
thread, because their cadence is known and silence is therefore unambiguous; a
quiet main on somebody's personal flake is reported and does not cry wolf.
Weekly, not nightly: end of life is a slow fact, and a daily failure about the
same unchanged branch is how an alert gets ignored.

It reports to a markdown file and does not decide what happens to it. Wrapping
it in asIssue turns that file into a forge issue that closes
itself when the branches move again; notify turns the failure into a push
message; on its own it is a red run with a table in its log.

Secrets

TruffleHog, twice over, because the two questions are different — and the
upstream thread this comes from is the one place a library gets to encode the
difference once instead of every repository rediscovering it.

moiraPipelines = t.fromProject { src = self; };
# => { ci = …; secret-scan = … }

The tree scan is the gate. Every push and pull request, no network at all,
and it fails on any detection — verified or not. It can afford a bar that strict
because an unverified hit in the working tree is still a hardcoded credential
somebody has to look at. An encrypted store stays deliberately in scope:
ciphertext produces no findings, and the failure worth catching is the
secrets.yml that never got encrypted.

The history scan asks the nightly question — "is anything we ever committed
still live?" — and it is the one that needs the network, because answering it
means letting trufflehog verify each candidate against the provider it belongs
to. verified,unknown only: the unverified findings already in a repository's
history are usually known-dead credentials from an older layout, and reporting
those every night is how a scanner gets ignored.

credentials found in the working tree:
  Github	config.py:1

That is the whole output. The locator, never the credential — a run log is
not where a freshly leaked secret should get its second home, so the report is
reduced to detector, file and line before anything reaches the log.

Three more things it has to get right, each of which is a way for a scanner to
lie:

  • Exit 183 means "results found". Any other non-zero is trufflehog itself
    failing, and reporting that as a leak — or worse, as a pass — is no better
    than not scanning. Both steps separate the two.
  • The history scan unshallows first. moira clones --depth=1, so there is
    no history in the workspace to scan. git.mode = "full" is the other way
    round, but this thread also runs on every push, where a full clone buys
    nothing — so the deepening lives in the step that needs it, which only runs on
    a schedule.
  • No needs_flake, deliberately. moira only gates a run on flake evaluation
    when a thread asks for a flake output, so this one still runs on a branch
    whose flake cannot evaluate. A secret gate that a broken flake skips is not a
    gate.

parallel = true rather than a cancel policy: a policy is scoped to the
repository and the thread name, so cancel would let any push kill the nightly
history run.

The exclude patterns are a library default — .git/, .direnv/, result,
.terraform/, node_modules/, target/ — written to a file inside the step,
so there is nothing to commit. excludes replaces the list, and excludeFile
points at a file a repository already maintains instead.

The trait fires on an encrypted secret store: .sops.yaml, .git-crypt/, or an
agenix secrets.nix beside a secrets/ directory. The argument is the one the
upstream comment makes — a repository that keeps secrets in-tree has decided
that secrets live there, which is exactly where one arriving in plaintext is a
gate worth having, and exactly where nobody thinks to add one because the
encrypted store feels like the answer. Every other repository can still have the
thread for one line: secretScan { } takes no required options. "Could leak a
credential" is true of every directory on earth, and a trait needs evidence.

Infrastructure

The first derived thread that talks back. A main.tf, terraform.tf or
versions.tf in the root, infra/, terraform/, deploy/ or tf/ plans a
terraform plan on pull requests, with the diff posted to the pull request as
one comment, edited in place on every push:

moiraPipelines = t.fromProject { src = self; };
# => { ci = …; tf-plan = … }

That is init → plan → comment_upsert, with the comment step always_run
(a plan that fails is when its output matters most) and gated on
when.event = "pull_request", because ${{ trigger.pr.number }} exists on a
pull request run and nowhere else. The marker carries the directory, so two
terraform directories in one repository keep two comments.

The apply is not derived. It is planned, and it requires the secrets:

evaluation warning: moira fromProject: skipping the 'tf-apply' thread that
  trait:terraform planned for .. It needs `secrets`, which no manifest can
  supply. Pass it to enable the thread:

    fromProject { src = self; tf-apply.secrets = …; };

Nothing in a *.tf file authorises a repository to change infrastructure from
CI, and the credentials it would need appear in no manifest — so the same rule
the docs bucket follows applies here, for a stronger reason. Naming them enables
it:

moiraPipelines = t.fromProject {
  src = self;
  tf-apply.secrets = [ "AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" ];
};

terraformApply is three steps, not two: init, plan, then apply of the
plan file the plan step wrote. The extra step is the whole value — a gate in
front of a bare apply approves an intention, while a gate in front of a plan
file approves a diff. The module says as much itself: apply ignores
auto_approve when a plan file is present, because the file carries its own
approval. The gate defaults on, concurrency is queue rather than cancel
(discarding a pending apply is fine; interrupting a running one is how state
gets locked by a run that no longer exists), and the push filter is the
configuration directory — which a branch push does consult, unlike a tag push.

terraformDrift is the third, and the only one that runs when nothing in the
repository changed: a nightly plan against the real infrastructure, for the hand
fix at 2am and the apply that half-failed. It never applies — reconciling is a
decision — and it is not derived, because a scheduled plan refreshes real state
and needs real credentials.

# red for as long as it is drifted; one issue, closed when it is clean again
drift = t.asIssue {
  thread = t.terraformDrift { dir = "infra"; secrets = [ "HCLOUD_TOKEN" ]; };
  title  = "infra has drifted from its configuration";
};

# red when the drift appears or changes, green while the same drift persists
drift = t.notify {
  topic  = "infra";
  thread = t.terraformDrift { dir = "infra"; stateBucket = "moira-state"; };
};

What it compares is the plan's new changes output — one action address line
per resource, update aws_s3_bucket.site, replace module.db.aws_instance.main
— not its text, whose attribute values and refresh lines differ run to run.

Hosts

A flake's nixosConfigurations plan one nixosDeploy each,
named deploy-<configuration>. The names are read from the flake's evaluated
outputs — which is why this needs src = self and stays silent for a bare path —
and only the names: detection never evaluates a configuration.

Every one of them is skipped with a warning until it is given a host key. A
deploy switches a running system as root, nothing in a flake authorises CI to
do that, and the key is the one value that cannot be read from the repository
without trusting whoever answers on the network:

moiraPipelines = t.fromProject {
  src = self;
  deploy-web.knownHosts = "web.example.com ssh-ed25519 AAAA…";
  deploy-web.host       = "web.example.com";   # default: the configuration name
};
# => { deploy-web = …; }  — and a warning for every host not yet given a key

moiraPipelines = t.fromProject { src = self; without = [ "trait:nixos" ]; };
# => this repository deploys some other way; no warnings

That is also the repository that could not use fromProject at all until now: an
infrastructure flake has no Cargo.toml, so it had no project for a trait to
attach to, and the call threw. A directory whose only manifest is flake.nix is
now a nix project — lowest priority, so a Rust root with a flake is still Rust —
which plans nothing of its own. A flake's CI is moiraFlake building
checks.*; see Nix gates for why that is not a thread.

What it actually reads

Detection is not pathExists "Cargo.toml". The value is in the contents:

Evidence What a generic template does What a derived thread does
no scripts.lint in package.json emits a lint step that fails omits the step
single crate, no [workspace] --workspace regardless plain cargo test (flag is redundant)
pnpm-lock.yaml + packageManager npm ci — wrong PM, resolves fresh pnpm install --frozen-lockfile
vendored .cargo/config.toml network = true everywhere network = false on every step
.sqlx/ present test step reaches for a live DB SQLX_OFFLINE=true in vars
monorepo, frontend-only push runs the whole graph on_push.paths skips the other thread
core = { path = "../core" } filters to api/** and hides breakage core/** is in the filter too
zensical.toml next to Cargo.toml docs publish is hand-written a second thread, derived
CHANGELOG.md with a ## [1.4.0] section release notes pasted by hand the tag's section, read at run time
Cargo.lock / go.sum / pnpm-lock.yaml no audit until the week it matters a nightly scan, with that language's scanner
infra/main.tf the plan lives in a run log the plan on the pull request, edited in place
renovate.json beside a flake.nix bot PRs fail on an unevaluable flake the lock is repaired on the bot's branch
flake.lock tracking nixos-25.11 nix flake update is a no-op forever the frozen branch is reported as EOL
.sops.yaml in the repository the plaintext one slips through a gate on every push, history nightly
rust-toolchain.toml present silently redownloads a toolchain warns, and does not touch your env
nixosConfigurations.web in self deploys are a shell script on a laptop deploy-web, once its host key is given
packages.*.containerImage in self the push is written by hand container, once given a registry path
flake.nix and nothing else fromProject threw: "no project" a nix project its traits attach to

Path filters

A subproject's filter is not its directory. Two sibling crates with no
[workspace] table and core = { path = "../core" } are two projects, and
api/** says nothing about core/ — so a change to core would leave ci-api
unscheduled and the branch green. A filter that hides breakage is worse than no
filter.

So the footprint is the project's directory plus the transitive closure of its
local dependencies, read out of the manifests that declare them:

Language Edge
Rust path = "../core", in dependencies, dev, build and [target.*]
Node file:, link: and portal: specifiers
Go a replace directive pointing at ./… or ../…
Python [tool.uv.sources] and poetry path dependencies
# api depends on core, core on nothing
ci-api  → paths = [ "api/**" "core/**" ]
ci-core → paths = [ "core/**" ]

Two limits, both deliberate. A project that depends on the repository root gets
no filter at all, because everything is inside it and "**" is not an honest
answer. And on_pr has no paths field in moira, so the filter is about pushes:
a pull request still runs every thread.

Precedence

constructor defaults  <  detected  <  plan  <  withDefaults  <  call-site args

Detection only ever improves the built-in defaults. Anything a person wrote —
a site default or a call-site argument — outranks it, so a value baked in once
never silently stops applying:

t.rustCi {
  src     = self;
  testRun = "cargo nextest run --workspace";   # wins over detection
}

plan is the layer fromProject adds: a thread's name among its siblings, the
directory it is for, its path filter, and whatever a detector's plan supplied. It
outranks detection because a constructor cannot work those out alone, and loses
to a site default because a person wrote that.

The cost is deliberate: an explicit setting that contradicts the repo stays
wrong. Detection reports those rather than correcting them — a pinned
rust-toolchain.toml warns instead of quietly rewriting your env.

An option that does not exist is an error, not a default left in place:

error: moira rustCi: unknown option(s) `testRum`.

       A misspelled option used to be silently ignored, leaving the default in
       place — so this is an error rather than a no-op.

Seeing what it thought

$ nix eval .#moiraProjectFacts --json | jq
{ "projects": [ { "path": ".", "kind": "rust", "workspace": true,
                  "members": [ "core", "cli" ], "sqlxOffline": true, … } ] }

Plain data, no builds, no running anything. Add it to a consumer flake with:

moiraProjectFacts = moira-modules.lib.${system}.detect { src = self; };

The three layers are separately usable: detect (src → facts),
planFor (facts → which constructor, where), and fromProject (both, applied).

Seeing what it decided

Facts answer "what did detection see?". They do not answer the question a reader
of a generated thread actually has, which is why is this value what it is? —
so every constructor also has an explain:

$ nix eval --json .#moiraExplain | jq '.explained.ci.constructor.byLayer'
{ "detected":  [ "clippyRun", "testRun" ],
  "plan":      [ "name", "path", "paths" ],
  "site":      [ "timeout" ],
  "call-site": [ "src" ] }
moiraExplain = moira-modules.lib.${system}.explainProject { src = self; };

# or one constructor at a time
t.explain.rustCi { src = self; }   # { options; provenance; byLayer; warnings; }

network is absent from detected here and present on a vendored repository:
it is the vendored trait's option now rather than each language's, so it is
only decided when there is something to decide. explainProject reports the
plan, each thread's provenance, and any thread a
plan wanted but could not build — and unlike fromProject it never throws, which
is the point: it has to survive the input that makes the real call fail.

Adding a language

The core knows nothing about any language — each is a detector, and a detector
is a value in a list. A repo with an in-house build system passes its own
rather than forking this flake:

t.fromProject {
  src = self;
  extraDetectors = [{
    kind     = "bazel";
    detect   = r: if r.exists "WORKSPACE" then { } else null;
    owns     = _: [ ];
    plan     = _: { use = "bazelCi"; };
    priority = 50;
  }];
}

detect returns null for "not mine" and an attrset of facts otherwise;
owns lists the subdirectories the project already accounts for, which is what
stops a Cargo workspace's members being detected a second time while still
letting an unrelated web/ be found. A detector with claims = false is a
trait — "has a flake", "is vendored", "publishes docs" — that contributes facts
without taking a directory away from a language.

A trait is not a lesser detector. It has the same vocabulary as a kind: it can
plan threads, it can supply options, and it can warn. The last two were the
missing half, and the cost of their absence is still legible in the git history:
"vendored" was detected by the trait for Rust and again inside the Go detector
from vendor/modules.txt, and the one consequence that matters — network = false, because a vendored tree resolves nothing over the network — was written
separately in each language's own options. Which is why two of the four
languages had it and two did not.

Now the word has one definition and one consequence:

{
  kind   = "vendored";
  claims = false;
  detect = r: …;                      # cargo's replace-with, or go's vendor/modules.txt
  options = _: { network = false; };  # the consequence, once, for every language
  warn   = facts: …;                  # vendored config, no vendor/ — see below
}

goCi still reads the fact for -mod=vendor, which is genuinely Go's
business. A trait's options are merged under the claiming kind's, so a
language detector still has the last word about its own language — both are
handed the same merged facts, so the kind has already seen whatever the trait
saw.

A trait warning earns its place on the one way vendoring goes wrong, and it goes
wrong because detection worked: with the network off, cargo cannot quietly
fall back to crates.io.

evaluation warning: ./.cargo/config.toml replaces crates-io with a vendored
  source, but there is no vendor/ directory in the repository — so the thread
  runs with the network off and cargo has nothing to resolve against. Check that
  vendor/ is not gitignored, or remove the replace-with stanza.

A detector's full vocabulary:

Field Means
kind The name facts and plans are attributed to
detect readers -> facts | null. Pure reads only — no import-from-derivation
claims false makes it a trait: facts, no directory, but a plan is still allowed
owns Subdirectories the claim accounts for, so the walk does not re-detect them
priority Highest wins a contested directory; ties break on kind
plan One plan or a list. { use; stem ? "ci"; args ? { }; requires ? [ ]; }
options Option defaults detection can justify — below anything a person wrote. A trait's are merged under the claiming kind's
warn What detection may report but not fix. A trait may warn too

stem names the thread (ci, ci-web; docs, docs-web), args carries what
detection knows and the constructor cannot work out for itself, and requires
names options only a person can supply. The readers detect is handed are
exists, readFile, readTOML, readJSON, subdirs, subdirsAt and
resolvePath — the last resolves a manifest-relative path like ../core into a
path relative to src, which is how local dependency edges are read without any
detector doing its own .. arithmetic.


Endpoints

A module is already a function: typed inputs, typed outputs, an implementation
built by Nix, distributed by the org cache. A webhook trigger is already an HTTP
contract: a route, methods, auth, and an explicit binding from request data into
a typed input schema. And a webhook can already answer with a step's output
rather than a receipt.

endpoint is the wiring, and the wiring is mechanical for one specific reason:
a module's inputs and a thread's inputs are the same type language. So the
input contract, the request bindings, the generated HTML form and the OpenAPI
document all follow from the module itself.

A module is not the only thing worth calling, though, so an endpoint is also a
list of steps, or a thread that already exists — one route builder, three things
behind it.

moiraPipelines.totp = t.endpoint {
  module  = moiraModules."auth/totp";
  path    = "/fn/totp";
  fixed   = { operation = "generate"; };     # not the caller's choice
  secrets = { secret = "TOTP_SEED"; };       # a secret name, never a value
  output  = "token";
  auth    = { type = "bearer"; secret = "FN_TOKEN"; };
};
$ curl -s -H 'Authorization: Bearer …' -d '{"digits":8}' \
    https://moira.example/orgs/acme/repos/tools/hooks/fn/totp
"03491121"

That produces a thread with git.mode = "none" (nothing to check out — the
definition already carries the module's store path), every non-fixed, non-secret
input declared with its own pattern/enum/minimum intact, a binding per
input, and response.mode = "output" pointing at the chosen output.

Option Does
module / steps / thread What answers — exactly one. See Three shapes
path Where it answers, below /orgs/<org>/repos/<repo>/hooks
methods Default [ "POST" ]. A body-less method binds from the query instead
auth member (default), public, bearer, hmac — the last two take a secret name
inputs The request contract. Derived from a module; declared by you for steps
fixed Set at build time, not exposed to callers. Where an operation enum belongs
secrets A secret name per type = "secret" input
exclude Not bound and not declared, so the module's own default stands
bindings Override one input's source: { ip = "headers.x-forwarded-for"; }
responseMode output (default) waits and answers; receipt is job submission — 202 and a run id
output Which output answers: "token", or "sign.token" with several steps
responseTimeout Default "30s". The server caps an output-mode wait at five minutes
form true, or an object, for a generated script-free HTML form at /forms/…
policy skip makes a second concurrent call answer 409 — single-flight as an HTTP status
network Default true; a pure compute handler should set it false

fixed, secrets and exclude are about a module's interface, so they only
mean anything alongside module — with steps you write the step, and a value
that is not the caller's to choose goes straight into it.

Everything else a thread has, an endpoint takes too: branches, schedule,
vars, timeout, agentLabels. A function that is also scheduled nightly is
one thread, not two.

Three shapes

A module is the common case and the smallest one. It is not the only thing worth
calling over HTTP, so endpoint takes three things and the route is identical
for all of them:

# 1. a module — the contract, the bindings and the form all follow from it
totp = t.endpoint { module = moiraModules."auth/totp"; path = "/fn/totp"; … };

# 2. steps — a function that is more than one call. Declare the contract.
geo = t.endpoint {
  path   = "/fn/geo";
  inputs = { ip = { type = "string"; description = "Address to resolve"; }; };
  steps  = [
    { name = "lookup"; use = moiraModules."http/http-request";
      "with" = { url = "https://ip.example/\${{ inputs.ip }}"; }; }
    { name = "shape"; use = "moira.map"; depends_on = [ "lookup" ];
      "with" = { items = "\${{ steps.lookup.outputs.body }}"; apply = ".city"; }; }
  ];
  output = "shape.items";
};

# 3. a thread that already exists, which gains a route rather than being rewritten
ci = t.endpoint {
  thread       = t.rustCi { src = self; };
  path         = "/fn/ci";
  responseMode = "receipt";
};

The third is the one worth dwelling on. t.rustCi { src = self; } keeps its push
trigger, its PR trigger, its env and its steps, and gains an HTTP route — so CI
on demand is the same thread as CI on push, with one run history and one
definition. A thread that already serves a route gains a second one rather than
losing the first, because moira's webhook trigger takes an object or a list.

Two things are derived rather than fixed. git.mode is none when every
step is a module — nothing to check out is what makes the call an exec rather
than a clone — and left alone when a step has a run, because that is
repository code and an endpoint that checked nothing out would run it against an
empty directory. A thread keeps whatever checkout it already asked for.
output is inferred when there is one candidate and named as step.output
when there are several.

Which output can answer is knowable for a module (it declares them) and for the
moira.* built-ins (moira.map, moira.filter, moira.sort, moira.unique,
moira.limit and moira.merge all emit items, which is what makes them
chainable), and genuinely unknowable for a run step, which writes
$MOIRA_OUTPUTS at run time. So a wrong output name is an error against the
first two and a required argument for the third — never a guess.

A function catalog

One module is one function. An org's internal tooling is a dozen, and writing
endpoint a dozen times means repeating the prefix and the credential a dozen
times — which is how eleven functions end up sharing a token and the twelfth
ends up public.

moiraPipelines = t.endpoints {
  prefix = "/fn";
  auth   = { type = "bearer"; secret = "FN_TOKEN"; };

  functions = {
    totp = {
      module  = moiraModules."auth/totp";
      fixed   = { operation = "generate"; };
      secrets = { secret = "TOTP_SEED"; };
      output  = "token";
    };
    sign = {
      module  = moiraModules."auth/jwt";
      fixed   = { action = "sign"; };
      secrets = { secret = "JWT_KEY"; };
      output  = "token";
    };
    # a browser-facing one, with its own auth and a generated form
    revoke = {
      module  = moiraModules."auth/jwt";
      fixed   = { action = "verify"; };
      secrets = { secret = "JWT_KEY"; };
      output  = "valid";
      auth    = { type = "member"; };
      form    = { title = "Check a token"; };
    };
  };
};
# => { totp = …; sign = …; revoke = …; }

Like fromProject, this is not a constructor: it returns an attrset of threads
because that is the type moiraPipelines has. Everything except prefix and
functions is a shared endpoint option, merged under each function's own, so
a function overrides what the catalog set. The catalog key names the thread —
renaming a route does not rename the thread its history, its metrics and its
on_workflow dependents are keyed by.

Two functions on one route is an error. The server matches one of them and the
other never answers, with nothing in either definition that says so:

error: moira endpoints: 1 function(s) share a route, and only one of them would ever answer:

         - sign and signature both serve POST /fn/sign

       Give one of each pair its own `path`, or different `methods`.

Different methods on one path are fine — that is one resource with two verbs.

$ nix eval .#moiraEndpoints --json | jq
{ "totp":   { "path": "/fn/totp", "methods": ["POST"], "auth": "bearer", "form": false, … },
  "revoke": { "path": "/fn/revoke", "methods": ["POST"], "auth": "member", "form": true, … } }

endpointRoutes answers "what does this expose, and behind what credential?"
without building anything — the counterpart to moiraProjectFacts, for an API
rather than a repository. explainEndpoints adds which layer decided each
option of each function. Neither of them builds a thread, so both still answer
when endpoints itself throws, which is the point of having them.

The rules it encodes

The part worth having is not the copying. Each of these is either a moira check error a person writing the endpoint by hand would meet later, or a
mistake nothing would catch — and each is reported here, with the option named:

  • a thread input may not be type = "secret", so a module's secret inputs are
    never bound from a request
  • a path input cannot come from an HTTP request at all
  • a generated form cannot use bearer or hmac auth, because a browser form
    cannot produce a machine credential — nor sit on a parameterised route, which
    has no fixed address to be served at
  • a {param} in the path that is not a bound input would receive nothing
  • fixed, secrets, exclude and bindings must name inputs the module
    actually declares
  • secrets must name a secret-typed input — a literal value belongs in fixed
  • path is a route and must start with /
  • methods must be ones moira's router serves, and auth.type one of the four
    it implements — an unknown value there is not a rejected option but a thread
    that fails to deserialise and is skipped
  • responseTimeout must parse, and must be inside the five-minute cap the
    server puts on an output-mode wait; past that the request is answered before
    the run finishes, so receipt is the honest mode
  • an ambiguous output is named, with the candidates listed
error: endpoint "totp": the module declares 3 outputs, so which one
       answers the request cannot be inferred. Name it:

         output = "remaining";

       Declared outputs: remaining, token, valid
error: endpoint "geo": which output answers the request cannot be inferred.
       Name it as step.output:

         output = "lookup.body";

       Available: lookup.body, lookup.status, shape.items

Everything a thread gets, an endpoint gets: a log, a status, a duration, a page,
recorded inputs and outputs, the metrics every run emits, and an entry in the
repository's OpenAPI document at …/webhooks/openapi.json, which the server
derives from these same schemas.


Findings

A red nightly run is a notification only for whoever opens the runs page. An
issue is where the rest of the team's work already is — it outlives the run's
retention, it can be assigned, labelled and discussed, and it can be closed,
which is the half that makes a detector a reconciler rather than a thing that
files issues forever.

moiraPipelines.audit = t.asIssue {
  thread = t.audit { run = "cargo audit"; };
  title  = "cargo audit is reporting advisories";
  labels = [ "security" ];
};

Like notify, this takes a thread and returns the same thread with steps
appended, so it composes with anything — including notify:

t.notify { topic = "ci"; thread = t.asIssue { thread = t.secretScan { }; … }; }
# => steps: tree, history, finding-body, finding-file, finding-clear, notify

And because a derived set is a plain attrset, one line makes every derived check
report this way:

moiraPipelines = lib.mapAttrs (_: th: t.asIssue { thread = th; }) (
  t.fromProject { src = self; }
);

The lifecycle

issue_upsert and issue_resolve on vcs/forge key an issue by a hidden
marker in its body rather than by its title, because a detector should be free
to improve the wording of what it reports without filing a duplicate of
everything it has ever found. The marker has two states, and that is the whole
design:

<!-- moira:finding:audit -->            live — open, tracked here
<!-- moira:finding:audit:resolved -->   retired — the finding cleared
What happens What the thread does
First failure Opens an issue carrying the live marker
Still failing Replaces the body of that issue, in place
Goes green Comments, closes it, and retires the marker
Fails again later Live marker gone, so this is a new occurrence — files a new issue
A person closes it without fixing it Live marker still there: left alone, nothing re-filed

That last row is why issue_upsert searches closed issues too. Closing one is
an answer, and re-filing it every night would be arguing with it — the run keeps
failing, which is the signal that matters. And retiring the marker on a genuine
fix is what stops "never reopen" from meaning "never report again".

What the issue says

The thread's own detecting step writes a markdown file; the wrapper reads it with
body_file. That is the only way a multi-line report crosses a step boundary —
a run step with no env has no jq to encode one into an output — and it is the
same mechanism release uses for changelog notes. A thread that writes nothing
still files something actionable, naming the run to read.

flake-staleness is the worked example: it writes a table of each input, its
branch and the age of that branch's head, plus the paragraph explaining why
nix flake update cannot fix it. It used to file issues itself, with its own
curl block and its own idempotency rule; one reporting layer for every check is
better than one per check, so it now writes a file and says nothing about where
it goes.

Two details worth knowing

Only the resolve step ignores a failure of its own. When the thread has
already failed, a filing error must stay visible: a reporting layer that quietly
fails is one you cannot trust, and the thread is red either way. When the thread
has passed, that is the fact it exists to report — a briefly unreachable forge
leaves a stale issue for the next green run to close, and turning a green check
red over bookkeeping is the false alarm all of this is trying to avoid.

always_run with no depends_on is a finally block in moira: it waits
for every other step. So only the first appended step is dependency-free, and
the other two hang off it — which keeps them last while MOIRA_STATUS still
reads the thread's real outcome. The body step records that outcome as an
output, and a when.eval on it picks between filing and resolving, as two steps
rather than one branching script, so a run's timeline says which happened.

A when.eval reads templates, not the environment. moira runs it without the
step's environment, so [ "$MOIRA_STATUS" = failure ] there never matches and
!= failure always does — which is exactly how this constructor used to file
nothing on a red run and close the issue instead. Anything an eval needs comes
through ${{ steps.<name>.outputs.<key> }}, and a run step, which does see
$MOIRA_STATUS, is how the thread's outcome becomes one.

The credential

Two ways in, and the choice is which one the instance can give you.

token names a secret holding a forge token with write:issue, provisioned by
hand. It is the default, because it works everywhere.

token = null uses the run's ambient issue credential instead: the wrapper
declares permissions = { issues = "write"; } on the thread it wraps, moira
mints a write:issue token for that run and places it in every step's
environment as MOIRA_FORGE_ISSUE_TOKEN, and vcs/forge reads it from there.
Nothing to provision, nothing to rotate:

moiraPipelines.audit = t.asIssue {
  thread = t.audit { run = "cargo audit"; };
  token  = null;                       # the run's own, no secret at all
};

The declaration is merged into the wrapped thread's own permissions, so a
thread that already writes repository contents keeps doing so.

It is not the default yet for one reason: the instance's forge connection has to
hold an issue token for there to be one to mint. Where it does not, the variable
is simply absent — absent, not empty, which is what makes [ -n "$MOIRA_FORGE_ISSUE_TOKEN" ] a usable test — and the filing step fails saying
which of the three possible reasons applies.

The run's clone credential is neither of these and cannot file anything: moira
scopes it to repository contents, and the forge keeps issue access as a separate
scope.

A pull request never receives the ambient credential, whatever the thread
declares — a PR run executes contributor code, and the bot's token reaches every
repository the bot can see. Nothing this wrapper is normally used on is
PR-triggered, so that costs nothing; wrapping a thread that is files nothing at
all, so asIssue warns at eval time and moira check warns on the instance. On
a pull request the right channel is a comment, and comment_upsert is the
operation.

One caution. secretScan should not be wrapped by default: a repository's
issues can be public, and "there is a live credential at deploy.sh:1 in commit
6b57b754" filed publicly is itself a disclosure. The module prints locators
and never credentials, but an issue is a louder channel than a run log and
deserves the deliberate choice.


Environments

Name Provides
rust cargo rustc clippy rustfmt pkg-config openssl
node nodejs npm
python python3 pip virtualenv
go go gopls gotools
docker docker docker-compose
nix nix jq fd git curl — the lock threads commit and file
aws awscli2
container skopeo + an accept-anything signature policy
buildah buildah skopeo — for manifest lists
terraform tofu — Terraform is unfree, so it is not bundled
audit cargo-audit govulncheck go nodejs pnpm yarn
scan trufflehog git jq
nix-lint nix nixfmt statix deadnix fd

Set on a thread (all steps share it) or on an individual step to override.

The rust env provides the toolchain directly. Do not add rustup default …
to a step that uses it: that fetches a toolchain over the network at run time
and discards the pinned, content-addressed one the env exists to supply.


Modules

Module What it does
http/http-request HTTP requests — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
vcs/git Git — clone, commit, push, describe, ls-remote, signing, 26 ops
vcs/forge Forgejo/Gitea API — releases, PRs, issues, comments, labels, statuses
remote/ssh Execute commands or transfer files over SSH
storage/s3 S3 storage — ls/list, cp, sync, put, cat, stat, exists, presign
state/kv Durable key/value state for a thread — set, get, cas, and changed
container/skopeo-push Push an OCI/Docker image (docker-archive) to a registry
files/compress Compress and extract tar, gz, bz2, xz, zip
crypto/crypto Hash, HMAC, sign, verify, key generation
auth/jwt Sign, verify, and decode JWTs (HS*, RS*, ES*, EdDSA)
auth/totp Generate and verify TOTP tokens
notify/ntfy Send a push notification via ntfy
mail/smtp Send an email over SMTP — text, HTML, attachments
iac/terraform OpenTofu/Terraform — init, plan (with change detection), apply
tasks/vikunja Vikunja — tasks, projects, comments, labels
calendar/caldav CalDAV — events, todos, date-range queries
home/home-assistant Home Assistant — services, states, events, templates

Modules have a typed interface — declared inputs and outputs. Inputs are passed via with; outputs are available in downstream steps as ${{ steps.NAME.outputs.KEY }}. Secret inputs take a secret name, not the value — the agent fetches and injects it at runtime.

The forge

vcs/git does the repository — clone, commit, tag, log, describe. vcs/forge
does the forge: releases, pull requests, issues, comments, labels and commit
statuses, over the Forgejo/Gitea API. The split is not cosmetic. A tag is a git
object and belongs to the git module; a release is a forge record with a body,
assets and a URL, and no amount of git reaches it. A thread that cuts a release
needs both.

It knows where it is running. server, owner, repo, sha, tag,
context and target_url all default from the run's own environment —
MOIRA_REPO_URL, MOIRA_REPO_OWNER, MOIRA_REPO_NAME, MOIRA_COMMIT_SHA,
MOIRA_COMMIT_REF — so commenting on a pull request takes a number and a body,
and nothing else. A step that has to be told which repository it is in is a step
that gets copied into the wrong repository eventually.

Operation group Operations
releases release_create release_update release_get release_latest release_list release_delete release_upload
pull requests pr_create pr_get pr_list pr_merge
issues issue_create issue_get issue_list issue_update issue_close
comments comment_create comment_upsert comment_list comment_delete
labels label_add label_remove label_list
statuses status_create status_list

Sticky comments

comment_upsert is the operation worth knowing about. A thread that comments on
every push turns a pull request into a transcript nobody reads. Upsert writes a
hidden marker into the comment and edits that comment in place next time, so a
plan diff or a size table has exactly one comment and it is always current:

plan-comment = {
  name = "plan-comment";
  use  = moiraModules."vcs/forge";
  secrets = [ "FORGE_TOKEN" ];
  "with" = {
    token     = "FORGE_TOKEN";
    operation = "comment_upsert";
    number    = "${{ trigger.pr.number }}";
    marker    = "terraform-plan";          # which comment is "this thread's"
    body      = "```\n${{ steps.plan.outputs.diff }}\n```";
  };
};

Two threads commenting on the same pull request with different markers keep
separate comments, and neither accumulates. The created output says which
happened — "false" means it edited the one that was already there.

A release thread

t.release is the constructor, and detection plans it from a changelog — see
Releases. What it does with this module is worth knowing, because
it is the one place a file crosses a step boundary:

release = t.release {
  notesFrom = "CHANGELOG.md";
  files     = [ "${{ flake.packages.app }}/bin/app" ];
};

That is a notes step and a publish step. The notes step extracts the
changelog section whose heading mentions the tag being released — ## [1.4.0],
## 1.4.0, ## v1.4.0 and ## [v1.4.0] are all the same heading — and writes
it to release-notes.md; the publish step passes that path as body_file.

The indirection is not stylistic. A run step that declares no env gets bash,
coreutils, grep, sed, awk and git, and no jq — so it cannot encode a
multi-line value into $MOIRA_OUTPUTS, and release notes are multi-line by
definition. Steps share a working directory, so a file crosses the boundary with
no escaping, no 100 KB output cap, and nothing to get wrong. body_file works
the same way for a PR comment whose body a previous step generated.

needs_flake follows from the ${{ flake.… }} references in files: a release
that attaches a built artifact waits for exactly that output, and one that
attaches a repository file waits for nothing.

Pushing a tag is a permissions question, not a forge-token one: a thread that
pushes needs permissions = { contents = "write"; }, which resolves to a
different forge credential for the clone. Reading and writing forge records
needs the API token above — write:repository for releases, comments, labels,
statuses and merges, write:issue for issues, and a read-only token for every
*_get and *_list.

The issue operations need no token of their own

token is optional for the issue_* operations alone. A thread that declares

permissions = { issues = "write"; };

is handed a write:issue token in every step's environment as
MOIRA_FORGE_ISSUE_TOKEN, and the module falls back to it when token is
unset — so filing and closing issues needs no hand-provisioned secret. That is
what asIssue uses when its token is null.

Only the issue half, and that boundary is the point: the ambient credential is
minted write:issue and read:user and carries no repository scope, so a
release, a commit status or a merge sent with it is a 403. Repository scope
arrives in the clone URL for permissions = { contents = "write"; } and is never
an API token, so those operations keep requiring token — and say so
immediately, rather than failing weeks later in somebody's release thread.

Comments and labels sit in the forge's issue category and would work; they still
require token, because commenting or labelling is nearly always one step of a
flow whose other steps need repository scope anyway, and one credential for the
whole flow beats a step that works next to a step that 403s. (issue_resolve
does post its closing comment on the ambient credential — the issue it closes is
its own.)

The credential is read from the environment, never interpolated:
${{ env.MOIRA_FORGE_ISSUE_TOKEN }} deliberately expands to nothing — the one
hole in env.* mirroring the injected environment — because an interpolated
credential ends up on a command line and in any log that echoes the script. Its
value is scrubbed from the run log the way a secret's is, and a pull request
never receives it at all.

A list operation emits items as a JSON array, which is the same shape
moira.filter, moira.sort and a for_each step consume — so "every open PR
whose base is main" is a filter over pr_list, not a script.


State

Everything else in this library is stateless by construction: a run reads the
repository, does something, reports. That is the right default, and it is also
why a whole class of thread could not be written at all — each of them needs the
same missing sentence, "…and tell me whether that is different from last
time"
:

  • a drift check that notifies when terraform plan starts showing changes,
    rather than every night forever
  • an audit that reports a new advisory instead of re-reading the same list
    until nobody looks
  • a deploy that skips when the closure it would ship is the one already running
  • a canary that knows which revision to roll back to

The first two are constructor options now — stateBucket on
audit and terraformDrift. The third turned out
not to want a bucket at all: the host already records what it is running, and
nixosDeploy reads it there. The rest of this section is for
writing the others.

state/kv is one S3 object per key — body is the value, no index, no schema —
and the operation that matters is set, whose interesting output is changed:

{
  name = "remember";
  use  = moiraModules."state/kv";
  secrets = [ "S3_ACCESS_KEY_ID" "S3_SECRET_ACCESS_KEY" ];
  "with" = {
    bucket    = "moira-state";
    endpoint  = "https://s3.example.com";
    access_key_id     = "S3_ACCESS_KEY_ID";
    secret_access_key = "S3_SECRET_ACCESS_KEY";
    operation = "set";
    key       = "drift/prod";
    value     = "${{ steps.plan.outputs.changes_present }}";
  };
}
{"value":"true","previous":"false","changed":"true","revision":"b326b506…"}

Then a notify step with when.eval = ''[ "''${{ steps.remember.outputs.changed }}" = true ]'';
fires on the transition and stays quiet while the state holds — a template,
not $MOIRA_STEP_REMEMBER_OUTPUT_CHANGED, which an eval cannot see. Not get: reading a value is easy, and
comparing it by hand in a shell step is where the bugs live — a missing key read
as an empty string, a trailing newline the store round-trips and a shell does
not, a value that [ "$a" = "$b" ] splits. Those three are handled once here.

Operation Does
get The value, or default when the key has never been written
set Writes, and reports changed and previous
cas Writes only if the stored revision still matches, or only if the key is absent
delete Removes it, reporting what was there
list The keys under a prefix, as an items array

set is last-writer-wins, which is correct for every thread above: one
scheduled drift check, one audit, one deploy behind policy = "queue".

cas is only a compare-and-swap if the store enforces conditional writes.
AWS S3 does; S3-compatible stores vary, and one that ignores If-Match turns
a compare-and-swap into an unconditional write that reports success — two
threads racing would both believe they won. So cas probes first, on a
throwaway key, and refuses rather than approximating:

error: this endpoint accepted a conditional write it should have rejected, so it
       does not enforce If-None-Match and cas here would be an unconditional
       write reporting success. Use operation=set with concurrency policy=queue,
       or set verify=false if you know better than this probe.

absent = true is the other half: a conditional create, which is exactly a
distributed lock — two threads race to write the same key and precisely one gets
swapped = true. A lost race is an outcome rather than an error, so the step
succeeds with swapped = false and a when: can branch on it; a step that
failed would make losing a race indistinguishable from a broken endpoint.

test_threads/live-state-kv.nix runs the whole sequence against a real store,
and doubles as a conformance test for it: a green run is also the answer to
"does my S3 enforce If-Match?".

There is a second shape this could have taken and deliberately did not. moira
records every step's outputs, so GET /orgs/…/runs/{id}/steps will say what the
last run emitted — no bucket at all. But it is read-only, it is bounded by
whatever run retention the server is configured with, and "what the last run
emitted" stops being "the current state" the moment a run fails halfway. Worth
having as its own module one day; it is not this one.


Checks

$ nix flake check

Three checks, all pure eval — no live service, nothing built:

Check Asserts
module-schema Every module has a description, typed inputs/outputs, and no input its implementation reads but never declares
detect Detection against fixture project trees in lib/detect/fixtures/
constructors What a constructor does with an options attrset — triggers, provenance, endpoint, and that no thread carries a field moira does not read

The interesting half of module-schema is the last clause: it compares the
declared inputs against what the implementation actually reads, which catches
an input that is read but undeclared (unsettable, silently stuck on its
default) or declared but never read (a silent no-op for the caller). moira
validates the inputs section itself, but only this side can see the
implementation source.

nix eval .#moiraModuleAudit --json shows the per-module detail behind it.

Those three checks are also this flake's own CI. moiraFlake declares
checks.*, so moira builds each of them as its own job on every push, and
threads/ci.nix is four lines of nixCi covering the one gate that is not a
flake output. Thread files in this repository are handed moiraLib, which is
what lets it build its pipelines out of the constructors it ships rather than
hand-writing what it asks everyone else to derive.

constructors exists because the other two could not see a whole class of bug.
Detection tests ask what the repository said; a thread is wrong in different ways.
Four of its cases are regressions where the generated thread was dropped rather
than rejected:

  • a thread-level when.paths — Thread has no when field, and
    moiraPipelines is deserialised without deny_unknown_fields, so the monorepo
    path filter never existed
  • needs_flake = true — NeedsFlake takes a string, a list or an object and no
    bool, so the thread failed to parse and was skipped with a warning
  • { } // { on_push.branches = …; } // { on_push.tags = …; } — // replaces
    on_push wholesale, so a release thread quietly stopped running on main
  • "\\$\\{\\{ … \\}\\}" — Nix's regex engine rejects an escaped brace, so
    deriving needs_flake from a ${{ flake.… }} reference could never work

Hence the shape assertion: every constructor's output is checked against the
field list in moira-model's Thread, because a field moira does not read is
indistinguishable from one it does until something does not run.

Module tests that need a real service live in test_threads/live-*.nix.
They are guarded by having manual = true and no other trigger, so moira never
schedules one on its own; each names the secrets it needs at the top. See
test_threads/live-README.md.


Incompatible changes

Four things changed shape in the revision that added endpoint, derived path
filters and provenance. Three of them were broken, which is why they changed:

Was Now Why
when.paths on a constructor paths, emitted as trigger.on_push.paths A thread has no when field; the filter was dropped
containerManifest { after = …; } onWorkflow One trigger vocabulary for every constructor
an unknown option was ignored an unknown option throws A misspelled option left the default in place, silently
detection set needsFlake on a flake repo it does not NeedsFlake takes no bool, so the thread failed to parse — and a check pipeline reads no flake output anyway
endpoint always set git.mode = "none" it is derived from the steps An extraSteps entry with a run had no repository to run against
endpoint built its own trigger it composes with the shared one branches, schedule and the rest were accepted and silently dropped
paths with a tags-only trigger an error A tag push matches on the tag alone, so the filter was never evaluated

Every constructor now accepts moira's whole trigger vocabulary (branches,
tags, paths, prTarget, prEvents, schedule, onWorkflow, webhook,
manual) and the thread-level fields description, gitMode, matrix,
agentLabels, inputs, permissions, public, timeout, vars,
sandboxDefaults.

Later, remote/ssh changed two defaults, both of which had been failing quietly:

Was Now Why
strict_host_key_checking = false true; pass known_hosts Any key that answered was accepted, with the credential and the command handed to it
a hard-coded 300s channel timeout timeout, total seconds, 0 = none It never fired — the module waited for the exit status with no limit — and waiting before reading deadlocked any command that printed more than the SSH window

See also