- Nix 96.8%
- Python 3.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The roadmap claimed a generic template passing --workspace unconditionally "produces a thread that cannot run", and that claim reached a detector comment, a test name and the README table. It is wrong. A package with no [workspace] table is an implicit workspace root of one, and cargo accepts the flag there — verified with `cargo check --workspace` on a single-crate fixture, which exits 0. Found by comparing generated output against a hand-written thread in a real repo that passes --workspace on exactly such a crate. Behaviour is unchanged: omitting the flag for a single crate is still the right default, it just keeps the command honest rather than fixing an error. The detections that actually change behaviour are `vendored` (drops the network) and `sqlxOffline` (keeps the test step off a live database). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWg4Anc99Zn5nvMo3Gxyyi |
||
| envs | ||
| lib | ||
| modules | ||
| test_threads | ||
| threads | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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/checks/ |
Eval-level schema check over modules/ |
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 |
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"; };
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 {
after = "container";
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.
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 when.paths filter derived from its own directory, so a push touching
only web/ does not schedule the backend thread at all.
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";
};
};
Container chaining is deliberately not inferred: confirming that
packages.containerImage exists would need import-from-derivation, which
detection does not do. Writing the one call above is the supported way.
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 | when.paths skips the other thread |
rust-toolchain.toml present |
silently redownloads a toolchain | warns, and does not touch your env |
Precedence
constructor defaults < detected < 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
}
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.
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).
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" — that contributes facts without taking a
directory away from a language.
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 |
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 |
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 |
remote/ssh |
Execute commands or transfer files over SSH |
storage/s3 |
S3 storage — ls/list, cp, sync, put, cat, stat, exists, presign |
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.
Checks
$ nix flake check
Two checks, both 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/ |
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.
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.
See also
- moira — the engine that runs these
- moira docs → Modules — full module interface spec and built-in modules
- moira docs → Environments — injected env vars and step environment model