Something went wrong. Try again.
A Rust DSL for Tangled workflows
Something went wrong. Try again.
14 kB · 487 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488//! 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<Constraint>,
#[serde(flatten)] engine: Engine,
#[serde(skip_serializing_if = "Vec::is_empty")] runs_on: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] clone: Option<CloneOpts>,
#[serde(skip_serializing_if = "IndexMap::is_empty")] environment: IndexMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty")] steps: Vec<Step>,}
impl Workflow { pub fn new(engine: impl Into<Engine>) -> 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<Item = impl Into<String>>) -> 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<String>, value: impl Into<String>) -> 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<String> { 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<String, Vec<String>>,}
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<String>, packages: impl IntoIterator<Item = impl Into<String>>, ) -> Self { self.dependencies .entry(registry.into()) .or_default() .extend(packages.into_iter().map(Into::into)); self }}
impl From<Nixery> 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<String>,
#[serde(skip_serializing_if = "Vec::is_empty")] dependencies: Vec<String>,
#[serde(skip_serializing_if = "IndexMap::is_empty")] registry: IndexMap<String, String>,
#[serde(skip_serializing_if = "IndexMap::is_empty")] caches: IndexMap<String, String>,
#[serde(skip_serializing_if = "IndexMap::is_empty")] services: IndexMap<String, Value>,
#[serde(rename = "virtualisation", skip_serializing_if = "IndexMap::is_empty")] virtualization: IndexMap<String, Value>,}
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<String>) -> 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<String>) -> Self { self.dependencies.push(package.into()); self }
pub fn dependencies(mut self, packages: impl IntoIterator<Item = impl Into<String>>) -> 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<String>, flake_ref: impl Into<String>) -> 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<String>, public_key: impl Into<String>) -> 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<String>, config: impl Into<Value>) -> 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<String>, config: impl Into<Value>) -> Self { self.virtualization.insert(name.into(), config.into()); self }}
impl From<Microvm> 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<Event>,
#[serde(skip_serializing_if = "Vec::is_empty")] types: Vec<PullRequestAction>,
#[serde(skip_serializing_if = "Vec::is_empty")] branch: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")] tag: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")] paths: Vec<String>,}
impl Constraint { pub fn event(events: impl IntoIterator<Item = Event>) -> 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<Item = PullRequestAction>) -> Self { self.types.extend(types); self }
/// Branch patterns to match. Supports globs using `*` and `**`. pub fn branch(mut self, branches: impl IntoIterator<Item = impl Into<String>>) -> 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<Item = impl Into<String>>) -> 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<Item = impl Into<String>>) -> 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<bool>,
#[serde(skip_serializing_if = "Option::is_none")] depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")] submodules: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] tags: Option<bool>,}
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<String, String>,}
impl Step { pub fn run(name: impl Into<String>, command: impl Into<String>) -> 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<String>, value: impl Into<String>) -> 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")); }}