Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/dwarves-runa/src/pipeline.rs

//! The typed pipeline schema — layer 1 (**shape**) and layer 2 (**vocabulary**)
//! of the `runa` design.
//!
//! A pipeline is a strongly-typed Rust value authored in **RON**. RON serialises
//! Rust enums / tagged variants natively (`Trigger::Cron`, `Step::CargoBuild`)
//! with comments + trailing commas, which is why the forge uses it over TOML.
//! These types are the single vocabulary shared across dwarves (the runner),
//! `xtask`, and — later — the visual builder, so the schema never forks.

use serde::{Deserialize, Serialize};

/// A whole CI/CD pipeline — the root of a `*.runa.ron` file.
///
/// Parse one with [`Pipeline::from_ron_str`], which *also validates* it (DAG
/// cycle-check + unknown-`needs` check) so a malformed pipeline **errors at load,
/// not at run**.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Pipeline {
    /// Human name of the pipeline (e.g. `"korp-ci"`).
    pub name: String,
    /// What causes the pipeline to run. Empty = never auto-triggered
    /// (invoke-only). Defaulted so `triggers` may be omitted in RON.
    #[serde(default)]
    pub triggers: Vec<Trigger>,
    /// WHERE the steps run, for every stage that does not say otherwise
    /// ([`Stage::runs_on`]). Defaulted (and never written back when it is the
    /// default), so every `*.runa.ron` from before it existed loads unchanged and
    /// means [`RunsOn::Container`].
    #[serde(default, skip_serializing_if = "RunsOn::is_default")]
    pub runs_on: RunsOn,
    /// The stage DAG. Ordering between stages is expressed by
    /// [`Stage::needs`], not by position in this vec.
    pub stages: Vec<Stage>,
}

impl Pipeline {
    /// Where `stage`'s steps run: the stage's own [`Stage::runs_on`] when it set
    /// one, else the pipeline's [`Pipeline::runs_on`].
    #[must_use]
    pub fn runs_on_for(&self, stage: &Stage) -> RunsOn {
        stage.runs_on.unwrap_or(self.runs_on)
    }
}

/// **Where a stage's steps run** — the `runs_on` setting of a [`Pipeline`]
/// (pipeline-wide) and of a [`Stage`] (its override).
///
/// The runner lowers every step that executes a program onto this: inside an
/// OCI container booted through draupnir (`draupnir::BootSpec::container`, the
/// default), or in a KVM guest booted through the same `BootSpec`. A value with
/// no backend yet is refused BY NAME at load ([`RunsOn::has_backend`]), never run
/// somewhere else in its place.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum RunsOn {
    /// An OCI container booted through draupnir over podman — the default.
    #[default]
    Container,
    /// A KVM guest booted through draupnir's `BootSpec` (tunnr).
    Vm,
    /// A Kubernetes pod. NO BACKEND YET: refused at load, by name.
    Kubernetes,
}

impl RunsOn {
    /// The default ([`RunsOn::Container`]) — not written back to RON.
    #[must_use]
    pub fn is_default(&self) -> bool {
        *self == RunsOn::default()
    }

    /// Is there a runner backend for this value? `Kubernetes` has none yet.
    #[must_use]
    pub fn has_backend(self) -> bool {
        !matches!(self, RunsOn::Kubernetes)
    }

    /// The RON spelling (`"Container"`, `"Vm"`, `"Kubernetes"`).
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            RunsOn::Container => "Container",
            RunsOn::Vm => "Vm",
            RunsOn::Kubernetes => "Kubernetes",
        }
    }
}

/// A stage's `runs_on` is spelled like the pipeline's (`runs_on: Vm`): the
/// `Option` is the field's presence, not a RON `Some(..)` the author must write.
mod bare_runs_on {
    use super::RunsOn;
    use serde::{Deserialize, Deserializer, Serializer};

    pub(super) fn ser<S: Serializer>(v: &Option<RunsOn>, s: S) -> Result<S::Ok, S::Error> {
        match v {
            Some(r) => serde::Serialize::serialize(r, s),
            None => s.serialize_none(),
        }
    }

    pub(super) fn de<'de, D: Deserializer<'de>>(d: D) -> Result<Option<RunsOn>, D::Error> {
        RunsOn::deserialize(d).map(Some)
    }
}

impl std::fmt::Display for RunsOn {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// What causes a [`Pipeline`] to run. All four variants are locked in v1 so the
/// type is stable even before every runner path is wired.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Trigger {
    /// A push to the named branch.
    Push {
        /// The branch that, when pushed, fires the pipeline (e.g. `"main"`).
        branch: String,
    },
    /// A cron schedule (standard 5-field crontab, e.g. `"0 3 * * *"`).
    Cron(String),
    /// Manual / on-demand invocation only.
    Manual,
    /// An inbound webhook (forge- or SCM-delivered).
    Webhook,
}

/// One stage of a pipeline — a named group of [`Step`]s that runs after all the
/// stages it [`needs`](Stage::needs) have completed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Stage {
    /// Stage name — must be unique within the pipeline and is the key other
    /// stages reference in their [`needs`](Stage::needs).
    pub name: String,
    /// Names of stages that must complete before this one starts. Defines the
    /// stage DAG edges (`need → this`). Defaulted so a root stage may omit it.
    #[serde(default)]
    pub needs: Vec<String>,
    /// This stage's override of [`Pipeline::runs_on`]; `None` = the pipeline's.
    /// Defaulted and skipped when unset, so old files load and re-save unchanged.
    /// Written bare (`runs_on: Vm`), never `Some(Vm)`.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        serialize_with = "bare_runs_on::ser",
        deserialize_with = "bare_runs_on::de"
    )]
    pub runs_on: Option<RunsOn>,
    /// The steps to run in this stage.
    pub steps: Vec<Step>,
}

/// A cargo build profile. Defaults to [`Profile::Debug`] (cargo's own default)
/// so `profile` may be omitted in RON.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Profile {
    /// `cargo build` (debug).
    #[default]
    Debug,
    /// `cargo build --release`.
    Release,
    /// A named custom cargo profile (`[profile.<name>]`).
    Named(String),
}

/// The policy an [`Step::Sbom`] supply-chain gate enforces.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SbomPolicy {
    /// Fail the pipeline on a policy violation (a hard gate).
    #[default]
    Block,
    /// Record the violation but let the pipeline continue.
    Warn,
}

/// Which linter a [`Step::Lint`] runs. Generalises the old Rust-only `Clippy`
/// step so `fmt` (and any future linter) share one vocabulary word.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum LintTool {
    /// `cargo clippy` — the Rust lint gate (the historical default).
    #[default]
    Clippy,
    /// `cargo fmt --check` — the formatting gate.
    Fmt,
}

/// What a [`Step::Trace`] / [`Step::Profile`] / [`Step::LiveTrace`] runs: a
/// test binary, a bench, or a binary of the checkout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum RunTarget {
    /// `cargo test` (`--test <name>`; no name = the package's own tests).
    #[default]
    Test,
    /// `cargo bench` (`--bench <name>`).
    Bench,
    /// `cargo run` (`--bin <name>`).
    Bin,
}

/// Which repos of a workspace a [`Step::BuildWorkspace`] reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum WorkspaceScope {
    /// Every member (or the ones `repos` names).
    #[default]
    All,
    /// Only the members a change reaches (Mímir's affected surface).
    Affected,
}

/// Which tests a test-running step ([`Step::Nextest`], [`Step::CargoTest`],
/// [`Step::Coverage`]) runs.
///
/// ```ron
/// Nextest(only: Affected)
/// CargoTest(only: Affected)
/// Coverage(only: Affected)
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TestScope {
    /// Every test (the default — and what a weekly / nightly full run uses).
    #[default]
    All,
    /// Only the tests whose reach in Mímir's code graph includes a function that
    /// changed since the repo's previous green commit (`Test.Affected` on the
    /// forge's nornir). Tests the graph does not know always run; when the graph
    /// cannot be sure (a `Cargo.toml`, a macro, a cfg change, no green baseline, the
    /// commit not read yet …) every test runs and the step says why.
    Affected,
}

/// The family of security scan a [`Step::SecurityScan`] performs. This is the
/// *scanning* side of supply-chain (SAST / SCA / secret / container-image); the
/// SBOM *emission* side stays [`Step::Sbom`] (modgunn).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScanKind {
    /// Static application security testing (code analysis).
    Sast,
    /// Dependency / software-composition analysis (vulnerable deps).
    Deps,
    /// Secret detection (leaked credentials in the tree/history).
    Secret,
    /// Built container-image vulnerability scan.
    Container,
}

/// Where a [`Step::Deploy`] delivers. The general deploy verb;
/// [`Step::DeployPages`] remains the one narrow wasm/pages special case.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeployTarget {
    /// Push/promote to an artifact or image registry.
    Registry {
        /// Registry name / URL (e.g. `"ghcr.io/nordisk"`).
        name: String,
    },
    /// Deploy to a named environment (e.g. `"staging"`, `"production"`).
    Env {
        /// The environment name.
        name: String,
    },
    /// Apply a Kubernetes manifest artifact to a cluster.
    K8s {
        /// Path to the manifest artifact to apply.
        manifest: String,
    },
}

/// The channel a [`Step::Notify`] fans out on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotifyChannel {
    /// Post to a Slack channel / target.
    Slack {
        /// The Slack channel or target id.
        target: String,
    },
    /// Send email to the given recipients.
    Email {
        /// Recipient addresses.
        #[serde(default)]
        to: Vec<String>,
    },
    /// POST to an arbitrary webhook URL.
    Webhook {
        /// The webhook endpoint.
        url: String,
    },
}

/// When a [`Step::Notify`] fires relative to the pipeline outcome.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum NotifyOn {
    /// Only on success.
    Success,
    /// Only on failure.
    #[default]
    Failure,
    /// On every completion regardless of outcome.
    Always,
}

/// The IaC backend a [`Step::ProvisionTestEnv`] uses to stand up an ephemeral
/// test system (service-container test deps stay jera `Container` payloads on
/// the Test step — this is the *whole-stack* case).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvisionBackend {
    /// A draupnir spec (KVM / OCI / bare-metal) for the repeatable full-system
    /// autotest — the native nordisk provisioned test system.
    Draupnir {
        /// Path to the draupnir spec artifact.
        spec: String,
    },
    /// A Terraform module artifact dwarves emits and applies (then destroys),
    /// for cloud targets. No inline `terraform` shell — the module is a
    /// content-addressed build artifact.
    Terraform {
        /// Path to the emitted Terraform module artifact.
        module: String,
    },
}

/// The v1 **step catalog** — layer 2, the readable vocabulary. Each variant wraps
/// one existing nordisk capability with typed params (like a type-checked GitHub
/// Actions `uses:`). Humans compose named steps, never raw shell; real branching
/// logic goes into a compiled [`Step::Xtask`].
///
/// # `deny_unknown_fields`, and why it is not tidiness (2026-08-27)
///
/// A field name this catalog does not know is now a LOAD ERROR. It used to be
/// silently discarded, and the moment `Step::Build` grew an option axis that turned
/// into the very defect the axis exists to prevent: an operator writes
///
/// ```text
/// Build(variant: "gunnar-appliance-iso", optz: ["wireguard=off"])
/// ```
///
/// and — before this attribute — got a green run, a mastered medium, and a
/// WireGuard client inside it, with nothing anywhere saying the toggle had been
/// thrown away. A customer choice dropped in silence is worse than a refusal, and
/// this is a *.runa.ron file in review, so a refusal costs one line of diff.
///
/// MEASURED: `a_misspelled_build_field_is_refused_at_load` failed against this
/// deserializer — RON accepted `optz:` and produced `Build { variant: "x", opts: [],
/// params: [] }`. Which also means the compatibility green next to it
/// (`bare_build_step_still_parses_without_opts_or_params`) was proving nothing at
/// the time: a deserializer that ignores every unknown field greens a bare form for
/// free. Both halves needed this.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Step {
    /// `cargo build` with the given feature set, profile and optional target triple.
    CargoBuild {
        /// Cargo features to enable.
        #[serde(default)]
        features: Vec<String>,
        /// The build profile.
        #[serde(default)]
        profile: Profile,
        /// Optional `--target <triple>`; `None` = host target.
        #[serde(default)]
        target: Option<String>,
    },
    /// `cargo nextest run` — the fast test runner.
    ///
    /// ```ron
    /// Nextest()
    /// Nextest(all_features: true)
    /// Nextest(only: Affected)      // only the tests a change reaches
    /// ```
    Nextest {
        /// Run with `--all-features`.
        #[serde(default)]
        all_features: bool,
        /// Every test (default), or only the ones a change reaches ([`TestScope`]).
        #[serde(default)]
        only: TestScope,
    },
    /// `cargo test` — the built-in test runner.
    ///
    /// ```ron
    /// CargoTest                    // = CargoTest(), every test
    /// CargoTest(only: Affected)    // only the tests a change reaches
    /// ```
    ///
    /// The bare `CargoTest` (a unit variant until 2026-10-17) still loads:
    /// [`Pipeline::from_ron_str`] reads it as `CargoTest()`.
    CargoTest {
        /// Every test (default), or only the ones a change reaches ([`TestScope`]).
        #[serde(default)]
        only: TestScope,
    },
    /// **Run the repo's `nornir-bench` example** — the bench harness every
    /// nordisk repo carries (`examples/`, `xtask/examples/`, a detached
    /// `bench/`) — and file what it measured into the workspace's warehouse
    /// (`bench_runs` / `bench_results`, and its own `test_outcomes`), keyed by the
    /// checkout's repo and commit.
    ///
    /// ```ron
    /// Bench()                            // examples/nornir-bench.rs at the root
    /// Bench(package: "xtask")            // xtask/examples/nornir-bench.rs
    /// Bench(dir: "bench")                // a detached bench/ workspace
    /// Bench(features: ["s3"])
    /// ```
    ///
    /// Every field is defaulted, so `Bench()` is the whole step for a repo whose
    /// example sits at the root.
    Bench {
        /// `-p <package>` when the example is not the root package's (`"xtask"`).
        #[serde(default)]
        package: String,
        /// A sub-directory of the checkout to run cargo in (`"bench"` for a
        /// detached bench workspace). A NAME path below the checkout: absolute or
        /// `..` is refused at the forge, never resolved.
        #[serde(default)]
        dir: String,
        /// Cargo features for the bench example.
        #[serde(default)]
        features: Vec<String>,
    },
    /// **Source coverage of the repo's tests** (`cargo llvm-cov`, LLVM
    /// source-based coverage), filed per function into the workspace's
    /// warehouse (`coverage` / `coverage_fn`), each function keyed by its file
    /// and line span so the code room's function joins it — keyed by the
    /// checkout's repo and commit.
    ///
    /// ```ron
    /// Coverage()
    /// Coverage(all_features: true)
    /// Coverage(only: Affected)     // instrumented build, only the tests a change
    ///                              // reaches; merged with the previous run
    /// ```
    Coverage {
        /// Run with `--all-features`.
        #[serde(default)]
        all_features: bool,
        /// Every test (default), or only the ones a change reaches ([`TestScope`]):
        /// the functions that did not change keep the previous run's coverage.
        #[serde(default)]
        only: TestScope,
    },
    /// **Trace every function call of a real run** (LLVM XRay): the target is
    /// built with `-Z instrument-xray=always` (every function gets entry/exit
    /// sleds, small ones too) and run with the XRay basic-mode runtime logging;
    /// the log is read back into (thread, time, function, enter/exit) events, each
    /// function mapped to the code room by its source location, and filed into
    /// the workspace's warehouse (`fn_trace_events`, `fn_trace_runs`) — the 3D
    /// code map replays it. Keyed by the checkout's repo and commit.
    ///
    /// ```ron
    /// Trace(target: Test, name: "code_room")            // cargo test --test code_room
    /// Trace(target: Test, name: "code_room", args: ["map_"])  // a filter after `--`
    /// Trace(target: Bin, name: "dwarves", args: ["--help"])
    /// Trace(target: Bench, name: "zip", package: "znippy")
    /// ```
    Trace {
        #[serde(default)]
        target: RunTarget,
        /// The test / bench / bin NAME (`""` = the package's own unit tests for
        /// `Test`; required for `Bench` and `Bin`).
        #[serde(default)]
        name: String,
        /// Arguments to the traced program (after `--`), one argv slot each.
        #[serde(default)]
        args: Vec<String>,
        /// `-p <package>` when the target is not the root package's.
        #[serde(default)]
        package: String,
        /// Keep at most this many events (the earliest); `0` = the forge's
        /// default (2 000 000).
        #[serde(default)]
        max_events: u64,
    },
    /// **Sample where a run spends its time** (`perf record -g`, no rebuild):
    /// call stacks sampled on the CPU clock, folded per function into self and
    /// total time and filed into `fn_profile` — the 3D code map's heat layer.
    /// Needs `perf_event_open` for the run (see the forge's note on the step):
    /// refused by name where the box does not allow it, never weakened.
    ///
    /// ```ron
    /// Profile(target: Test, name: "code_room")
    /// Profile(target: Bin, name: "dwarves", args: ["serve", "--once"])
    /// ```
    Profile {
        #[serde(default)]
        target: RunTarget,
        #[serde(default)]
        name: String,
        #[serde(default)]
        args: Vec<String>,
        #[serde(default)]
        package: String,
    },
    /// **Watch chosen functions of a running program live** (eBPF uprobes via
    /// `bpftrace`): each entry / return of the named functions streams to the
    /// warehouse in short chunks while it runs, and the 3D map shows them as they
    /// come. OPT-IN and privileged: attaching uprobes needs `CAP_BPF` +
    /// `CAP_PERFMON` (or root) on the box that runs it, so the forge runs it only
    /// where its operator allowed it (`DWARVES_ALLOW_UPROBES=1`) and refuses it
    /// by name everywhere else. Never in a container.
    ///
    /// ```ron
    /// LiveTrace(target: Bin, name: "dwarves", args: ["serve"], functions: ["dwarves::serve::*"], seconds: 60)
    /// ```
    LiveTrace {
        #[serde(default)]
        target: RunTarget,
        #[serde(default)]
        name: String,
        #[serde(default)]
        args: Vec<String>,
        #[serde(default)]
        package: String,
        /// Demangled function paths (a trailing `*` = every function below a
        /// module); at most 64 probes.
        functions: Vec<String>,
        /// How long to watch (`0` = until the program exits, at most 600 s).
        #[serde(default)]
        seconds: u32,
    },
    /// Lint gate — generalises the former Rust-only `Clippy` step over a
    /// [`LintTool`] so `fmt`/clippy/… share one vocabulary word.
    Lint {
        /// Which linter to run.
        #[serde(default)]
        tool: LintTool,
        /// Treat warnings as errors (`-D warnings`).
        #[serde(default)]
        deny_warnings: bool,
    },
    /// Materialise a **build-thing** variant **by name**. `runa` does not
    /// duplicate build-thing's package × bin × feature × profile × target
    /// vocabulary — it references a declared variant and dwarves invokes
    /// build-thing to build it.
    ///
    /// # The CUSTOMER CHOICE rides here (2026-08-27)
    ///
    /// A declared cell can carry an option axis (`[[build.iso.options]]`,
    /// `[[build.installer.options]]`) and a parameter axis
    /// (`[[build.iso.params]]`) — the two things that decide WHICH appliance a
    /// medium is. Both the ⚒ pane and `dwarves buildthing --opt/--param` have
    /// been able to express them; a `*.runa.ron` carried only the variant NAME,
    /// so the CI door could not build a per-site medium at all. It could only
    /// name the recipe and take its defaults.
    ///
    /// Both fields are `#[serde(default)]`, so every shipped pipeline's bare
    /// `Build(variant: "x")` parses byte-for-byte as before (asserted by
    /// `bare_build_step_still_parses_without_opts_or_params`). They travel to
    /// the forge as the same `--opt` / `--param` argv the two other doors
    /// spell, and are judged by the same
    /// `assemble_cell::resolve_cell_options` — there is no second resolver.
    Build {
        /// The build-thing variant name (e.g. `"holger-embedded"`).
        variant: String,
        /// **The OPT-OUT toggles**, `name=on|off` — verbatim as
        /// `dwarves buildthing --opt` spells them (e.g. `"wireguard=off"`).
        /// An undeclared name is refused BY NAME at the forge, never applied
        /// to nothing.
        #[serde(default)]
        opts: Vec<String>,
        /// **The per-site parameter values**, `name=value` — verbatim as
        /// `dwarves buildthing --param` spells them (e.g.
        /// `"admin_key=ssh-ed25519 AAAA…"`). A `@fixture` value resolves
        /// against the cell's declared fixtures.
        #[serde(default)]
        params: Vec<String>,
    },
    /// **Read a whole workspace into its code room — the fast workspace build
    /// (builder 1)**: every member compiled ONCE per unit by the planner
    /// (one combined unit graph, one target dir, shared units built once),
    /// its facts read by the Mímir driver while it compiles, per-unit state on
    /// the run. Executed by dwarves as jera jobs into draupnir containers.
    ///
    /// ```ron
    /// BuildWorkspace(workspace: "nordisk")
    /// BuildWorkspace(workspace: "nordisk", only: Affected)
    /// BuildWorkspace(workspace: "nordisk", repos: ["skade", "nornir"])
    /// ```
    BuildWorkspace {
        /// The registered workspace NAME (never a path).
        workspace: String,
        /// Every member, or only the ones a change reaches.
        #[serde(default)]
        only: WorkspaceScope,
        /// Narrow to these member NAMES (empty = all of `only`).
        #[serde(default)]
        repos: Vec<String>,
    },
    /// Build a container image from the given container file (podman, no shell).
    ContainerBuild {
        /// Path to the container definition file.
        file: String,
    },
    /// Boot the built artifact in a KVM/microVM as a smoke gate (tunnr).
    VmBoot,
    /// Supply-chain SBOM gate (modgunn).
    Sbom {
        /// How a violation is treated.
        policy: SbomPolicy,
    },
    /// Package the holger executables for the given targets (holger).
    HolgerPackage {
        /// Target triples / package targets to produce.
        #[serde(default)]
        targets: Vec<String>,
    },
    /// Run the nornir release doctor (pre-publish gate matrix).
    ReleaseDoctor,
    /// Publish crates to the registry (nornir release).
    PublishCrates,
    /// Build + deploy the wasm/pages site (xtask). The one narrow pages case;
    /// the general deploy verb is [`Step::Deploy`].
    DeployPages,
    /// Run a security scan of the given [`ScanKind`] (SAST / deps / secret /
    /// container). The scanning side of supply-chain — SBOM emission is
    /// [`Step::Sbom`].
    SecurityScan {
        /// Which scan family to run.
        kind: ScanKind,
    },
    /// Push a built container image to a registry under the given tags — the
    /// *push* split out of [`Step::ContainerBuild`] (mirrors
    /// `docker/build-push-action`).
    PushImage {
        /// Target registry (e.g. `"ghcr.io/nordisk"`).
        registry: String,
        /// Tags to publish (e.g. `["latest", "1.2.3"]`).
        #[serde(default)]
        tags: Vec<String>,
    },
    /// The general deploy verb — deliver to a registry, environment or k8s
    /// cluster ([`DeployTarget`]). Container-by-default via jera.
    Deploy {
        /// Where the deploy lands.
        target: DeployTarget,
    },
    /// A manual approval gate — pauses the stage DAG until a human approves
    /// (the protected-environment / `when: manual` / `input` step).
    ApprovalGate {
        /// Who may approve (usernames / teams). Empty = anyone authorised.
        #[serde(default)]
        approvers: Vec<String>,
        /// Optional auto-expiry (a human duration such as `"24h"`, `"30m"`);
        /// `None` = wait indefinitely.
        #[serde(default)]
        timeout: Option<String>,
    },
    /// Notify a [`NotifyChannel`] on the given outcome ([`NotifyOn`]).
    Notify {
        /// Where the notification goes.
        channel: NotifyChannel,
        /// When it fires relative to the pipeline outcome.
        #[serde(default)]
        on: NotifyOn,
    },
    /// Provision an ephemeral IaC test environment ([`ProvisionBackend`]) for
    /// integration/e2e, optionally auto-torn-down. The whole-stack complement to
    /// jera service-container test deps.
    ProvisionTestEnv {
        /// The IaC backend that stands up the env.
        backend: ProvisionBackend,
        /// Auto-destroy the env when the stage completes.
        #[serde(default)]
        teardown: bool,
    },
    /// The escape hatch: run a compiled, tested `xtask` subcommand — the typed,
    /// unit-testable replacement for Groovy. All real branching/computation lives
    /// here, not in the RON.
    ///
    /// This is the step that makes a runa something a Rust developer WRITES BY
    /// HAND beside their `xtask`: the verbs they already wrote and tested become
    /// callable pipeline steps, with no new machinery and no shell.
    ///
    /// ```ron
    /// Xtask(cmd: "fmt-check")
    /// Xtask(cmd: "build", args: ["--variant", "korp-ui"], repo: "korp")
    /// ```
    ///
    /// `args` and `repo` are defaulted, so every `*.runa.ron` written before they
    /// existed stays valid under `deny_unknown_fields`.
    Xtask {
        /// The `xtask` subcommand to invoke (e.g. `"verify-demo"`).
        cmd: String,
        /// Arguments AFTER the subcommand, verbatim and in order: `["--variant",
        /// "korp-ui"]`. Each element is one argv slot — never a string the runner
        /// splits, because a splitter is a shell in disguise and the whole
        /// catalog exists to not have one (`Step` has no `Shell` variant, and
        /// `spawn_process` never calls `sh -c`).
        #[serde(default)]
        args: Vec<String>,
        /// Which checkout to run in: a constellation member NAME (`"korp"`),
        /// resolved by the runner as a sibling of the forge's root. Empty = the
        /// forge's own repo, which is what every pre-0.2.3 `Xtask` step meant.
        ///
        /// A NAME and not a path, deliberately. A pipeline step that could name
        /// an absolute path would be a door onto the filesystem wearing a
        /// build's clothes; the runner refuses anything absolute, empty-segmented
        /// or containing `..` BY NAME rather than resolving it.
        #[serde(default)]
        repo: String,
        /// The cargo ALIAS the verb is reached through. Empty = `xtask`, which is what
        /// every pre-0.2.4 step meant (`cargo xtask <cmd>`).
        ///
        /// THE LAW gives xtask ONE dependency, the repo's recipe, so a repo whose
        /// non-build verbs (publish, verify, deploy) needed more moved them to a
        /// sibling bin behind its own alias — facett: `ops`, so `Xtask(cmd:
        /// "publish-holger", alias: "ops")` runs `cargo ops publish-holger`. A NAME
        /// only (`[a-z0-9_-]+`, see [`alias_is_a_name`]): anything else is refused at
        /// LOAD by name, never split and never resolved as a path.
        #[serde(default)]
        alias: String,
    },

    // ── full-system-autotest catalog (the North-Star harness, `.nornir/
    //    full-system-autotest-design.md`) ─────────────────────────────────────
    // The orchestrator's three NEW step kinds. Each lowers onto an ALREADY-BUILT
    // engine — jera boot/ledger + draupnir OCI (`SpinFleetMember`), the per-app
    // robotui/web scenarios (`RobotDriveMember`), and nornir-testmatrix
    // `discover::Surface` + `coverage::GateReport` (`CoverageGate`) — never new
    // machinery. The lowerings live in the `fleet` crate's harness (the edda-side
    // reference dwarves' `runa_exec` mirrors).
    /// **Fleet spin** (provision leg) — bring one fleet member up as an OCI
    /// container through jera's boot ledger → draupnir `run_to_completion`, then
    /// hold on a healthcheck barrier until the member is Ready (draupnir
    /// `functional_status`). The fleet-of-one MVP's provision step; teardown is
    /// RampGuard-style RAII (the fleet handle drops → the member is killed).
    SpinFleetMember {
        /// The member's stable name — the fleet key it's driven + gated under.
        name: String,
        /// OCI image reference to run (produced by a prior build-thing `Container`
        /// variant / `Build` step, or a draupnir `build_image`).
        image: String,
        /// Container ports to publish (host == container) — the served surface the
        /// web/robot leg drives against.
        #[serde(default)]
        ports: Vec<u16>,
        /// Extra container environment as `(KEY, VALUE)` pairs.
        #[serde(default)]
        env: Vec<(String, String)>,
        /// The member's declared **viz-tab surface** — the tabs the coverage gate
        /// requires proven (each tab must be driven both fat AND thin).
        #[serde(default)]
        surface: Vec<String>,
    },

    /// **Robot-drive a fleet member** (drive leg) — run the member's egui
    /// (co-located kittest, **fat** mode) + web (`nornir robot` WebDriver, **thin**
    /// mode) scenarios against the spun container, streaming `TestResultRow`s to
    /// the warehouse. Each scenario names the viz tab it drives; the egui leg is
    /// co-located (the honest `cmd_json` gap, design §6.1), the web leg is genuinely
    /// remote against the served port.
    RobotDriveMember {
        /// The fleet member (a prior [`SpinFleetMember`](Step::SpinFleetMember)
        /// `name`) to drive.
        member: String,
        /// Co-located egui/kittest scenarios — each names the viz tab it drives
        /// (covers `viz_tab:<tab>@fat`).
        #[serde(default)]
        egui_scenarios: Vec<String>,
        /// Web/WebDriver scenarios run against the container's served port — each
        /// names the viz tab it drives (covers `viz_tab:<tab>@thin`).
        #[serde(default)]
        web_scenarios: Vec<String>,
    },

    /// **A program in a SEALED container** (lane RELBUILD-RUNA, 2026-10-29): the
    /// step's own argv in `image`, with nothing but what the step names. The forge runs it
    /// with a read-only root filesystem, every capability dropped, `no-new-privileges`,
    /// the image by digest and never pulled, no network unless `network` says so, and
    /// only `mounts` and `tmpfs` (no `$HOME`, no shared cache, no checkout of the forge's).
    /// The steps of a release build are these. Refused at load: an image not named by
    /// digest, an empty argv, a mount that is not absolute on both sides or climbs (`..`),
    /// and an env name that looks secret.
    Sandbox {
        /// The image, by digest (`…@sha256:<hex>`).
        image: String,
        /// The argv, verbatim (never a string the runner splits).
        cmd: Vec<String>,
        /// Environment as `(NAME, VALUE)` pairs.
        #[serde(default)]
        env: Vec<(String, String)>,
        /// Bind mounts, host directory onto container path.
        #[serde(default)]
        mounts: Vec<Mount>,
        /// tmpfs mounts.
        #[serde(default)]
        tmpfs: Vec<Tmpfs>,
        /// Network on (`true`) or none (`false`, the default).
        #[serde(default)]
        network: bool,
        /// The working directory inside the container (empty = the image's).
        #[serde(default)]
        workdir: String,
    },

    /// **A named action of the forge itself** (lane RELBUILD-RUNA): work the runner
    /// does in-process between program steps, by name, from the actions it was given
    /// (a release build checks out each pin, checks the vendored crates against the
    /// locks and stages each packaged crate here). No program, no container. A name the
    /// runner does not know fails the step by name; `action` must be a NAME.
    Forge {
        /// The action's name (`[A-Za-z0-9_.-]+`).
        action: String,
        /// Its arguments, verbatim.
        #[serde(default)]
        args: Vec<String>,
    },

    /// **Coverage gate** (proof leg) — the `Gap == ∅` verdict over the fleet's
    /// driven surface: nornir-testmatrix `discover::Surface − Covered − Allow`, the
    /// HARD zero (`coverage::GateReport`). RED — naming the exact untested tab —
    /// when any declared surface node went undriven this run.
    CoverageGate {
        /// The workspace label the gate is scoped to (e.g. `"nornir"`).
        workspace: String,
        /// Surface keys (`kind:id@mode`) explicitly excused from the gate
        /// (recorded on the report, never silently dropped).
        #[serde(default)]
        allow: Vec<String>,
    },
}

/// A bind mount of a [`Step::Sandbox`]: `host` (absolute) onto `path` (absolute) in
/// the container, read-only when `read_only`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mount {
    /// The host directory (absolute, no `..`).
    pub host: String,
    /// Where it appears in the container (absolute, no `..`).
    pub path: String,
    /// Read-only (default: writable).
    #[serde(default)]
    pub read_only: bool,
}

/// A tmpfs of a [`Step::Sandbox`]: `path` in the container, at most `size_mb` MiB.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tmpfs {
    /// Where it is mounted (absolute).
    pub path: String,
    /// Its size cap in MiB.
    pub size_mb: u32,
}

/// Is `p` an absolute path with no `..`, `.` or empty segment, other than `/` itself?
#[must_use]
pub fn is_plain_absolute(p: &str) -> bool {
    p.len() > 1 && p.starts_with('/') && p[1..].split('/').all(|s| !s.is_empty() && s != "." && s != "..")
}

/// Is `image` named by digest (`<name>@sha256:<64 hex>`)?
#[must_use]
pub fn image_by_digest(image: &str) -> bool {
    image.split_once("@sha256:").is_some_and(|(n, h)| !n.is_empty() && h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
}

/// Does an environment NAME look like it carries a secret (`TOKEN`, `SECRET`, `PASSWORD`,
/// `PASSWD`, `CREDENTIAL`, `PRIVATE`, or a `CARGO_REGISTRY*`, `SSH_*` or `AWS_*` name)?
#[must_use]
pub fn env_looks_secret(name: &str) -> bool {
    let n = name.to_ascii_uppercase();
    ["TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "PRIVATE"].iter().any(|w| n.contains(w))
        || n.starts_with("CARGO_REGISTRY")
        || n.starts_with("SSH_")
        || n.starts_with("AWS_")
}

/// **The cargo alias an [`Step::Xtask`] step runs through** — `xtask` when the step
/// names none (the pre-0.2.4 meaning), else the step's own alias.
#[must_use]
pub fn xtask_alias(alias: &str) -> &str {
    let a = alias.trim();
    if a.is_empty() {
        "xtask"
    } else {
        a
    }
}

/// Is `alias` a cargo alias NAME (`[a-z0-9_-]+`, or empty = `xtask`)? A path, a
/// space (a second argv slot in disguise) or any other character is not.
#[must_use]
pub fn alias_is_a_name(alias: &str) -> bool {
    alias.is_empty() || alias.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}

/// Is `name` a workspace / member NAME (`[A-Za-z0-9_.-]+`, no `..`)? A path,
/// a space or any other character is not.
#[must_use]
pub fn is_a_name(name: &str) -> bool {
    !name.is_empty() && name != "." && name != ".." && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') && !name.contains("..")
}