No description
  • Nix 96.8%
  • Python 3.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JMARyA a8b4d42ea8
fix(detect): --workspace is redundant for a single crate, not invalid
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
2026-09-03 01:24:04 +02:00
envs feat(iac/terraform): OpenTofu/Terraform module and env 2026-09-02 22:04:27 +02:00
lib fix(detect): --workspace is redundant for a single crate, not invalid 2026-09-03 01:24:04 +02:00
modules feat(home/home-assistant): call services, read states, render templates 2026-09-03 00:58:29 +02:00
test_threads test(live): live module tests against real services, manual-only 2026-09-03 01:15:11 +02:00
threads fix(modules): use pythonPackages directly and disable style checks 2026-05-23 02:01:34 +02:00
flake.lock init: standard library foundation 2026-04-19 20:37:56 +02:00
flake.nix feat(detect): derived threads — constructors that read the project 2026-09-03 01:15:36 +02:00
README.md fix(detect): --workspace is redundant for a single crate, not invalid 2026-09-03 01:24:04 +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/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