//! A Rust eDSL for defining [Tangled](https://tangled.org) workflows. use indexmap::IndexMap; use serde::Serialize; pub use serde_yaml::Value; /// A single workflow, corresponding to one file in `.tangled/workflows`. #[derive(Debug, Clone, Serialize)] pub struct Workflow { #[serde(skip_serializing_if = "Vec::is_empty")] when: Vec, #[serde(flatten)] engine: Engine, #[serde(skip_serializing_if = "Vec::is_empty")] runs_on: Vec, #[serde(skip_serializing_if = "Option::is_none")] clone: Option, #[serde(skip_serializing_if = "IndexMap::is_empty")] environment: IndexMap, #[serde(skip_serializing_if = "Vec::is_empty")] steps: Vec, } impl Workflow { pub fn new(engine: impl Into) -> Self { Self { when: Vec::new(), engine: engine.into(), runs_on: Vec::new(), clone: None, environment: IndexMap::new(), steps: Vec::new(), } } /// Adds a trigger constraint. The workflow runs if any constraint matches. pub fn when(mut self, constraint: Constraint) -> Self { self.when.push(constraint); self } pub fn runs_on(mut self, runners: impl IntoIterator>) -> Self { self.runs_on.extend(runners.into_iter().map(Into::into)); self } pub fn clone_opts(mut self, clone: CloneOpts) -> Self { self.clone = Some(clone); self } /// Sets a workflow-wide environment variable. /// /// Do not put secrets here; these values are visible to anyone viewing /// the repository. pub fn env(mut self, key: impl Into, value: impl Into) -> Self { self.environment.insert(key.into(), value.into()); self } pub fn step(mut self, step: Step) -> Self { self.steps.push(step); self } pub fn to_yaml(&self) -> serde_yaml::Result { serde_yaml::to_string(self) } } /// The engine a workflow runs on, along with its engine-specific fields. #[derive(Debug, Clone, Serialize)] #[serde(tag = "engine", rename_all = "lowercase")] pub enum Engine { Nixery(Nixery), Microvm(Microvm), } /// The Nixery engine: each step runs in a fresh container built from a /// Nixery image containing the requested dependencies. #[derive(Debug, Clone, Default, Serialize)] pub struct Nixery { #[serde(skip_serializing_if = "IndexMap::is_empty")] dependencies: IndexMap>, } impl Nixery { pub fn new() -> Self { Self::default() } /// Adds dependencies from a registry (e.g. `nixpkgs`, or a flake URL like /// `git+https://tangled.org/@example.com/my_pkg`). pub fn dependencies( mut self, registry: impl Into, packages: impl IntoIterator>, ) -> Self { self.dependencies .entry(registry.into()) .or_default() .extend(packages.into_iter().map(Into::into)); self } } impl From for Engine { fn from(nixery: Nixery) -> Self { Engine::Nixery(nixery) } } /// The microVM engine: the whole workflow runs inside its own microVM. #[derive(Debug, Clone, Default, Serialize)] pub struct Microvm { #[serde(skip_serializing_if = "Option::is_none")] image: Option, #[serde(skip_serializing_if = "Vec::is_empty")] dependencies: Vec, #[serde(skip_serializing_if = "IndexMap::is_empty")] registry: IndexMap, #[serde(skip_serializing_if = "IndexMap::is_empty")] caches: IndexMap, #[serde(skip_serializing_if = "IndexMap::is_empty")] services: IndexMap, #[serde(rename = "virtualisation", skip_serializing_if = "IndexMap::is_empty")] virtualization: IndexMap, } impl Microvm { pub fn new() -> Self { Self::default() } /// Sets the image to boot (e.g. `nixos`, `alpine`). Falls back to the /// spindle's default image when unset. pub fn image(mut self, image: impl Into) -> Self { self.image = Some(image.into()); self } /// Adds a package available to every step. A bare name like `go` is /// looked up in nixpkgs; `flakeref#attr` syntax works too. pub fn dependency(mut self, package: impl Into) -> Self { self.dependencies.push(package.into()); self } pub fn dependencies(mut self, packages: impl IntoIterator>) -> Self { self.dependencies .extend(packages.into_iter().map(Into::into)); self } /// Remaps a flake reference, the same way `nix registry` does. pub fn registry(mut self, name: impl Into, flake_ref: impl Into) -> Self { self.registry.insert(name.into(), flake_ref.into()); self } /// Adds a Nix binary cache URL and its trusted public key. pub fn cache(mut self, url: impl Into, public_key: impl Into) -> Self { self.caches.insert(url.into(), public_key.into()); self } /// Configures a NixOS service (anything under `services.*`). /// /// `true` works as shorthand for `.enable = true`. pub fn service(mut self, name: impl Into, config: impl Into) -> Self { self.services.insert(name.into(), config.into()); self } /// Configures NixOS virtualization (anything under `virtualisation.*`). /// /// `true` works as shorthand for `.enable = true`. pub fn virtualization(mut self, name: impl Into, config: impl Into) -> Self { self.virtualization.insert(name.into(), config.into()); self } } impl From for Engine { fn from(microvm: Microvm) -> Self { Engine::Microvm(microvm) } } /// A trigger constraint under the `when` field. #[derive(Debug, Clone, Serialize)] pub struct Constraint { event: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] types: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] branch: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] tag: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] paths: Vec, } impl Constraint { pub fn event(events: impl IntoIterator) -> Self { Self { event: events.into_iter().collect(), types: Vec::new(), branch: Vec::new(), tag: Vec::new(), paths: Vec::new(), } } /// Restricts which pull request actions trigger the workflow. Only /// applies to [`Event::PullRequest`]. Defaults to opened, reopened, and /// synchronize. pub fn types(mut self, types: impl IntoIterator) -> Self { self.types.extend(types); self } /// Branch patterns to match. Supports globs using `*` and `**`. pub fn branch(mut self, branches: impl IntoIterator>) -> Self { self.branch.extend(branches.into_iter().map(Into::into)); self } /// Tag patterns to match. Only applies to [`Event::Push`]. Supports globs /// using `*` and `**`. pub fn tag(mut self, tags: impl IntoIterator>) -> Self { self.tag.extend(tags.into_iter().map(Into::into)); self } /// Only run if any changed file matches one of these glob patterns. pub fn paths(mut self, paths: impl IntoIterator>) -> Self { self.paths.extend(paths.into_iter().map(Into::into)); self } } /// An event that can trigger a workflow. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum Event { Push, PullRequest, Manual, } /// A pull request lifecycle action, matched against a constraint's `types`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum PullRequestAction { Opened, Reopened, Closed, Merged, Synchronize, } /// Options for how the repository is cloned before the workflow runs. #[derive(Debug, Clone, Default, Serialize)] pub struct CloneOpts { #[serde(skip_serializing_if = "Option::is_none")] skip: Option, #[serde(skip_serializing_if = "Option::is_none")] depth: Option, #[serde(skip_serializing_if = "Option::is_none")] submodules: Option, #[serde(skip_serializing_if = "Option::is_none")] tags: Option, } impl CloneOpts { pub fn new() -> Self { Self::default() } /// Skips cloning the repository entirely. pub fn skip(mut self, skip: bool) -> Self { self.skip = Some(skip); self } /// Sets the clone depth. Defaults to 1. pub fn depth(mut self, depth: u32) -> Self { self.depth = Some(depth); self } /// Recursively fetches all submodules. pub fn submodules(mut self, submodules: bool) -> Self { self.submodules = Some(submodules); self } /// Whether to fetch tags. pub fn tags(mut self, tags: bool) -> Self { self.tags = Some(tags); self } } /// A single step in a workflow. Steps run serially in a Bash shell. #[derive(Debug, Clone, Serialize)] pub struct Step { name: String, command: String, #[serde(skip_serializing_if = "IndexMap::is_empty")] environment: IndexMap, } impl Step { pub fn run(name: impl Into, command: impl Into) -> Self { Self { name: name.into(), command: command.into(), environment: IndexMap::new(), } } /// Sets an environment variable for this step. /// /// Do not put secrets here; these values are visible to anyone viewing /// the repository. pub fn env(mut self, key: impl Into, value: impl Into) -> Self { self.environment.insert(key.into(), value.into()); self } } #[cfg(test)] mod tests { use indoc::indoc; use pretty_assertions::assert_eq; use super::*; #[test] fn test_nixery_workflow() { let workflow = Workflow::new( Nixery::new() .dependencies("nixpkgs", ["nodejs", "go"]) .dependencies("git+https://tangled.org/@example.com/my_pkg", ["my_pkg"]), ) .when(Constraint::event([Event::Push, Event::Manual]).branch(["main", "develop"])) .when(Constraint::event([Event::PullRequest]).branch(["main"])) .clone_opts(CloneOpts::new().skip(false).depth(1).submodules(false)) .env("GOOS", "linux") .env("GOARCH", "arm64") .step(Step::run("Build backend", "go build").env("GOOS", "darwin")) .step(Step::run("Build frontend", "npm run build").env("NODE_ENV", "production")); let expected = indoc! {r#" when: - event: - push - manual branch: - main - develop - event: - pull_request branch: - main engine: nixery dependencies: nixpkgs: - nodejs - go git+https://tangled.org/@example.com/my_pkg: - my_pkg clone: skip: false depth: 1 submodules: false environment: GOOS: linux GOARCH: arm64 steps: - name: Build backend command: go build environment: GOOS: darwin - name: Build frontend command: npm run build environment: NODE_ENV: production "#}; assert_eq!(workflow.to_yaml().unwrap(), expected); } #[test] fn test_microvm_workflow() { let workflow = Workflow::new( Microvm::new() .image("nixos") .dependencies(["gcc", "cargo", "rustc", "pkg-config", "openssl"]) .registry("nixpkgs", "github:nixos/nixpkgs/nixos-unstable") .cache( "https://nix-community.cachix.org", "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=", ) .virtualization("docker", true), ) .when(Constraint::event([Event::Push]).tag(["v*"])) .step(Step::run("Test", "cargo test --all")); let expected = indoc! {r#" when: - event: - push tag: - v* engine: microvm image: nixos dependencies: - gcc - cargo - rustc - pkg-config - openssl registry: nixpkgs: github:nixos/nixpkgs/nixos-unstable caches: https://nix-community.cachix.org: nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= virtualisation: docker: true steps: - name: Test command: cargo test --all "#}; assert_eq!(workflow.to_yaml().unwrap(), expected); } #[test] fn test_microvm_service_with_structured_config() { let config: Value = serde_yaml::from_str(indoc! {r#" enable: true ensureDatabases: ["spindle-workflow"] "#}) .unwrap(); let workflow = Workflow::new(Microvm::new().image("nixos").service("postgresql", config)) .step(Step::run("Migrate", "sqlx migrate run")); let yaml = workflow.to_yaml().unwrap(); assert!(yaml.contains("postgresql:")); assert!(yaml.contains("enable: true")); assert!(yaml.contains("ensureDatabases:")); } #[test] fn test_pull_request_types() { let workflow = Workflow::new(Nixery::new()).when( Constraint::event([Event::PullRequest]) .types([PullRequestAction::Merged]) .branch(["main"]), ); let yaml = workflow.to_yaml().unwrap(); assert!(yaml.contains("types:\n - merged")); } }