Repository: axodotdev/cargo-dist
Pull request #553 (2023-11-07 19:24:42)

feat: enable generic builds
This PR adds a new "generic" build type. Most of the process is identical to Cargo builds, but I've extended our build logic to add a new `GenericBuildStep` that encapsulates a large part of the different behaviour.

This is an MVP, and it's missing some features we likely want to see in the future:

* Only workspaces are supported; we don't have a concept of packages yet. Since we don't inherit anything like that from Cargo, we'll have to think about what this looks like.
* The user has to manually specify all of the binaries that the build output will contain, since we don't have Cargo to get that information from. In the future, we probably want to make these globs instead of literal paths so that we can accommodate more complex builds.

We defer most of the project loading and parsing logic to axoproject, like we do with Cargo projects.

Depends on https://github.com/axodotdev/axoproject/pull/45 being in a release before this is mergeable.

mistydemeo: Modulo one bugfix, this is ready for review and good to go.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index c8ce9a57..05193fb5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -155,16 +155,16 @@ dependencies = [
 
 [[package]]
 name = "axoproject"
-version = "0.5.0"
+version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b13503c331b8e2d419c29a0614ec8f9b7c95ace01b045cafd7c465d2fe777e1a"
+checksum = "8da04fda61e491249006babba8a2f32927369edfb3328abef9590ef97c24a564"
 dependencies = [
  "axoasset",
  "camino",
  "clap",
  "console",
  "guppy",
- "itertools 0.10.5",
+ "itertools 0.11.0",
  "miette",
  "parse-changelog",
  "pathdiff",
@@ -1151,15 +1151,6 @@ version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "616cde7c720bb2bb5824a224687d8f77bfd38922027f01d825cd7453be5099fb"
 
-[[package]]
-name = "itertools"
-version = "0.10.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
-dependencies = [
- "either",
-]
-
 [[package]]
 name = "itertools"
 version = "0.11.0"
diff --git a/cargo-dist/Cargo.toml b/cargo-dist/Cargo.toml
index 774f7141..7614fcaf 100644
--- a/cargo-dist/Cargo.toml
+++ b/cargo-dist/Cargo.toml
@@ -34,7 +34,7 @@ axotag = "0.1.0"
 cargo-dist-schema = { version = "=0.4.2", path = "../cargo-dist-schema" }
 
 axoasset = { version = "0.6.0", features = ["json-serde", "toml-serde", "toml-edit", "compression"] }
-axoproject = { version = "0.5.0", default-features = false, features = ["cargo-projects"] }
+axoproject = { version = "0.6.0", default-features = false, features = ["cargo-projects", "generic-projects"] }
 
 comfy-table = "7.0.1"
 miette = { version = "5.6.0" }
diff --git a/cargo-dist/src/announce.rs b/cargo-dist/src/announce.rs
index f0b17cb4..54b5195d 100644
--- a/cargo-dist/src/announce.rs
+++ b/cargo-dist/src/announce.rs
@@ -263,7 +263,7 @@ fn check_dist_package(
             }
         }
         ReleaseType::Version(ver) => {
-            if pkg.version.as_ref().unwrap().cargo() != ver {
+            if pkg.version.as_ref().unwrap().semver() != ver {
                 return Some(format!("didn't match tag {}", announcing.tag));
             }
         }
@@ -438,7 +438,7 @@ fn possible_tags<'a>(
     let mut versions = SortedMap::<&Version, Vec<PackageIdx>>::new();
     for pkg_idx in rust_releases {
         let info = graph.workspace().package(pkg_idx);
-        let version = info.version.as_ref().unwrap().cargo();
+        let version = info.version.as_ref().unwrap().semver();
         versions.entry(version).or_default().push(pkg_idx);
     }
     versions
@@ -486,7 +486,7 @@ fn tag_help(
     let some_tag = format!(
         "--tag={}-v{}",
         info.name,
-        info.version.as_ref().unwrap().cargo()
+        info.version.as_ref().unwrap().semver()
     );
 
     writeln!(
diff --git a/cargo-dist/src/cargo_build.rs b/cargo-dist/src/cargo_build.rs
index 4e3521c5..14b2f366 100644
--- a/cargo-dist/src/cargo_build.rs
+++ b/cargo-dist/src/cargo_build.rs
@@ -3,10 +3,11 @@
 use std::env;
 use std::process::Command;
 
-use camino::{Utf8Path, Utf8PathBuf};
+use camino::Utf8PathBuf;
 use miette::{miette, Context, IntoDiagnostic};
 use tracing::{info, warn};
 
+use crate::env::{calculate_ldflags, fetch_brew_env, parse_env, select_brew_env};
 use crate::{
     copy_file, CargoBuildStep, CargoTargetFeatureList, CargoTargetPackages, DistGraph, FastMap,
     RustupStep, SortedMap,
@@ -142,22 +143,8 @@ pub fn build_cargo_target(dist_graph: &DistGraph, target: &CargoBuildStep) -> Re
     let mut rustflags = target.rustflags.clone();
     let mut desired_extra_env = vec![];
     let skip_brewfile = env::var("DO_NOT_USE_BREWFILE").is_ok();
-    if let Some(brew) = &dist_graph.tools.brew {
-        if Utf8Path::new("Brewfile").exists() && !skip_brewfile {
-            // Uses `brew bundle exec` to just print its own environment,
-            // allowing us to capture what it generated and decide what
-            // to do with it.
-            let result = Command::new(&brew.cmd)
-                .arg("bundle")
-                .arg("exec")
-                .arg("--")
-                .arg("/usr/bin/env")
-                .output()
-                .into_diagnostic()
-                .wrap_err_with(|| "failed to exec brew bundle exec".to_string())?;
-
-            let env_output = String::from_utf8_lossy(&result.stdout).to_string();
-
+    if !skip_brewfile {
+        if let Some(env_output) = fetch_brew_env(dist_graph)? {
             let brew_env = parse_env(&env_output)?;
             desired_extra_env = select_brew_env(&brew_env);
             rustflags = determine_brew_rustflags(&rustflags, &brew_env);
@@ -357,108 +344,10 @@ pub fn rustup_toolchain(_dist_graph: &DistGraph, cmd: &RustupStep) -> Result<()>
     Ok(())
 }
 
-// Takes a string in KEY=value environment variable format and
-// parses it into a BTreeMap. The string syntax is sh-compatible, and also the
-// format returned by `env`.
-// Note that we trust the parsed string to contain a given key only once;
-// if specified more than once, only the final occurrence will be included.
-fn parse_env(env_string: &str) -> DistResult<SortedMap<&str, &str>> {
-    let mut parsed = SortedMap::new();
-    for line in env_string.trim_end().split('\n') {
-        let Some((key, value)) = line.split_once('=') else {
-            return Err(DistError::EnvParseError {
-                line: line.to_owned(),
-            });
-        };
-        parsed.insert(key, value);
-    }
-
-    Ok(parsed)
-}
-
-/// Given the environment captured from `brew bundle exec -- env`, returns
-/// a list of all dependencies from that environment and the opt prefixes
-/// to those packages.
-fn formulas_from_env(environment: &SortedMap<&str, &str>) -> Vec<(String, String)> {
-    let mut packages = vec![];
-
-    // Set by Homebrew/brew bundle - a comma-separated list of all
-    // dependencies in the recursive tree calculated from the dependencies
-    // in the Brewfile.
-    if let Some(formulastring) = environment.get("HOMEBREW_DEPENDENCIES") {
-        // Set by Homebrew/brew bundle - the path to Homebrew's "opt"
-        // directory, which is where links to the private cellar of every
-        // installed package lives.
-        // Usually /opt/homebrew/opt or /usr/local/opt.
-        if let Some(opt_prefix) = environment.get("HOMEBREW_OPT") {
-            for dep in formulastring.split(',') {
-                // Unwrap here is safe because `split` will always return
-                // a collection of at least one item.
-                let short_name = dep.split('/').last().unwrap();
-                let pkg_opt = format!("{opt_prefix}/{short_name}");
-                packages.push((dep.to_owned(), pkg_opt));
-            }
-        }
-    }
-
-    packages
-}
-
-/// Takes a BTreeMap of key/value environment variables produced by
-/// `brew bundle exec` and decides which ones we want to keep for our own builds.
-/// Returns a Vec containing (KEY, value) tuples.
-fn select_brew_env(environment: &SortedMap<&str, &str>) -> Vec<(String, String)> {
-    let mut desired_env = vec![];
-
-    // Several of Homebrew's environment variables are safe for us to use
-    // unconditionally, so pick those in their entirety.
-    if let Some(value) = environment.get("PKG_CONFIG_PATH") {
-        desired_env.push(("PKG_CONFIG_PATH".to_owned(), value.to_string()))
-    }
-    if let Some(value) = environment.get("PKG_CONFIG_LIBDIR") {
-        desired_env.push(("PKG_CONFIG_LIBDIR".to_owned(), value.to_string()))
-    }
-    if let Some(value) = environment.get("CMAKE_INCLUDE_PATH") {
-        desired_env.push(("CMAKE_INCLUDE_PATH".to_owned(), value.to_string()))
-    }
-    if let Some(value) = environment.get("CMAKE_LIBRARY_PATH") {
-        desired_env.push(("CMAKE_LIBRARY_PATH".to_owned(), value.to_string()))
-    }
-    let mut paths = vec![];
-
-    // For each listed dependency, add it to the PATH
-    for (_, pkg_opt) in formulas_from_env(environment) {
-        // Not every package will have a /bin or /sbin directory,
-        // but it's safe to add both to the PATH just in case.
-        paths.push(format!("{pkg_opt}/bin"));
-        paths.push(format!("{pkg_opt}/sbin"));
-    }
-
-    if !paths.is_empty() {
-        let our_path = env!("PATH");
-        let desired_path = format!("{our_path}:{}", paths.join(":"));
-
-        desired_env.insert(0, ("PATH".to_owned(), desired_path));
-    }
-
-    desired_env
-}
-
 /// Similar to the above, we read Homebrew's recursive dependency tree and
 /// then append link flags to cargo-dist's rustflags.
 /// These ensure that Rust can find C libraries that may exist within
 /// each package's prefix.
 fn determine_brew_rustflags(base_rustflags: &str, environment: &SortedMap<&str, &str>) -> String {
-    let mut rustflags = base_rustflags.to_owned();
-    // For each listed dependency, add it to CFLAGS/LDFLAGS
-    for (_, pkg_opt) in formulas_from_env(environment) {
-        // Note that this path might not actually exist; not every
-        // package contains libraries. However, it's safe to
-        // append this flag anyway; Rust passes it on to the
-        // compiler/linker, which tolerate missing directories
-        // just fine.
-        rustflags = format!("{rustflags} -L{pkg_opt}/lib");
-    }
-
-    rustflags
+    format!("{base_rustflags} {}", calculate_ldflags(environment))
 }
diff --git a/cargo-dist/src/config.rs b/cargo-dist/src/config.rs
index db103ddd..df27f823 100644
--- a/cargo-dist/src/config.rs
+++ b/cargo-dist/src/config.rs
@@ -2,8 +2,8 @@
 
 use std::collections::BTreeMap;
 
-use axoasset::toml_edit;
-use axoproject::WorkspaceSearch;
+use axoasset::{toml_edit, SourceFile};
+use axoproject::{WorkspaceKind, WorkspaceSearch};
 use camino::{Utf8Path, Utf8PathBuf};
 use miette::Report;
 use semver::Version;
@@ -16,6 +16,13 @@ use crate::{
     TargetTriple, METADATA_DIST,
 };
 
+/// A container to assist deserializing metadata from generic, non-Cargo projects
+#[derive(Debug, Deserialize)]
+pub struct GenericConfig {
+    /// The dist field within dist.toml
+    pub dist: DistMetadata,
+}
+
 /// Contents of METADATA_DIST in Cargo.toml files
 #[derive(Serialize, Deserialize, Debug, Default, Clone)]
 #[serde(rename_all = "kebab-case")]
@@ -973,6 +980,23 @@ impl std::fmt::Display for ProductionMode {
     }
 }
 
+pub(crate) fn parse_metadata_table_or_manifest(
+    workspace_type: WorkspaceKind,
+    manifest_path: &Utf8Path,
+    metadata_table: Option<&serde_json::Value>,
+) -> DistResult<DistMetadata> {
+    match workspace_type {
+        // Pre-parsed Rust metadata table
+        WorkspaceKind::Rust => parse_metadata_table(manifest_path, metadata_table),
+        // Generic dist.toml
+        WorkspaceKind::Generic => {
+            let config: GenericConfig =
+                SourceFile::load_local(manifest_path)?.deserialize_toml()?;
+            Ok(config.dist)
+        }
+    }
+}
+
 pub(crate) fn parse_metadata_table(
     manifest_path: &Utf8Path,
     metadata_table: Option<&serde_json::Value>,
@@ -992,29 +1016,41 @@ pub(crate) fn parse_metadata_table(
 pub fn get_project() -> Result<axoproject::WorkspaceInfo> {
     let start_dir = std::env::current_dir().expect("couldn't get current working dir!?");
     let start_dir = Utf8PathBuf::from_path_buf(start_dir).expect("project path isn't utf8!?");
-    match axoproject::rust::get_workspace(&start_dir, None) {
-        WorkspaceSearch::Found(mut workspace) => {
-            // This is a goofy as heck workaround for two facts:
-            //   * the convenient Report approach requires us to provide an Error by-val
-            //   * many error types (like std::io::Error) don't impl Clone, so we can't
-            //     clone axoproject Errors.
-            //
-            // So we temporarily take ownership of the warnings and then pull them back
-            // out of the Report with runtime reflection to put them back in :)
-            let mut warnings = std::mem::take(&mut workspace.warnings);
-            for warning in warnings.drain(..) {
-                let report = Report::new(warning);
-                warn!("{:?}", report);
-                workspace.warnings.push(report.downcast().unwrap());
+    let workspaces = axoproject::get_workspaces(&start_dir, None);
+
+    let mut missing = vec![];
+
+    for ws in [workspaces.rust, workspaces.generic] {
+        match ws {
+            WorkspaceSearch::Found(mut workspace) => {
+                // This is a goofy as heck workaround for two facts:
+                //   * the convenient Report approach requires us to provide an Error by-val
+                //   * many error types (like std::io::Error) don't impl Clone, so we can't
+                //     clone axoproject Errors.
+                //
+                // So we temporarily take ownership of the warnings and then pull them back
+                // out of the Report with runtime reflection to put them back in :)
+                let mut warnings = std::mem::take(&mut workspace.warnings);
+                for warning in warnings.drain(..) {
+                    let report = Report::new(warning);
+                    warn!("{:?}", report);
+                    workspace.warnings.push(report.downcast().unwrap());
+                }
+                return Ok(workspace);
             }
-            Ok(workspace)
+            WorkspaceSearch::Broken {
+                manifest_path: _,
+                cause,
+            } => {
+                return Err(Report::new(cause)
+                    .wrap_err("We encountered an issue trying to read your workspace"))
+            }
+            // Ignore the missing case; iterate through to the next project type
+            WorkspaceSearch::Missing(e) => missing.push(e),
         }
-        WorkspaceSearch::Missing(e) => Err(Report::new(e).wrap_err("no cargo workspace found")),
-        WorkspaceSearch::Broken {
-            manifest_path: _,
-            cause,
-        } => Err(Report::new(cause).wrap_err("your cargo workspace has an issue")),
     }
+
+    Err(Report::new(DistError::ProjectMissing { sources: missing }))
 }
 
 /// Load a Cargo.toml into toml-edit form
diff --git a/cargo-dist/src/env.rs b/cargo-dist/src/env.rs
new file mode 100644
index 00000000..da5b81df
--- /dev/null
+++ b/cargo-dist/src/env.rs
@@ -0,0 +1,147 @@
+//! Functions to parse and manipulate the environment
+
+use std::process::Command;
+
+use camino::Utf8Path;
+use miette::{Context, IntoDiagnostic};
+
+use crate::{
+    errors::{DistError, DistResult, Result},
+    DistGraph, SortedMap,
+};
+
+/// Fetches the Homebrew environment from `brew bundle exec`
+pub fn fetch_brew_env(dist_graph: &DistGraph) -> Result<Option<String>> {
+    if let Some(brew) = &dist_graph.tools.brew {
+        if Utf8Path::new("Brewfile").exists() {
+            // Uses `brew bundle exec` to just print its own environment,
+            // allowing us to capture what it generated and decide what
+            // to do with it.
+            let result = Command::new(&brew.cmd)
+                .arg("bundle")
+                .arg("exec")
+                .arg("--")
+                .arg("/usr/bin/env")
+                .output()
+                .into_diagnostic()
+                .wrap_err_with(|| "failed to exec brew bundle exec".to_string())?;
+
+            return Ok(Some(String::from_utf8_lossy(&result.stdout).to_string()));
+        }
+    }
+
+    Ok(None)
+}
+
+/// Takes a string in KEY=value environment variable format and
+/// parses it into a BTreeMap. The string syntax is sh-compatible, and also the
+/// format returned by `env`.
+/// Note that we trust the parsed string to contain a given key only once;
+/// if specified more than once, only the final occurrence will be included.
+pub fn parse_env(env_string: &str) -> DistResult<SortedMap<&str, &str>> {
+    let mut parsed = SortedMap::new();
+    for line in env_string.trim_end().split('\n') {
+        let Some((key, value)) = line.split_once('=') else {
+            return Err(DistError::EnvParseError {
+                line: line.to_owned(),
+            });
+        };
+        parsed.insert(key, value);
+    }
+
+    Ok(parsed)
+}
+
+/// Given the environment captured from `brew bundle exec -- env`, returns
+/// a list of all dependencies from that environment and the opt prefixes
+/// to those packages.
+fn formulas_from_env(environment: &SortedMap<&str, &str>) -> Vec<(String, String)> {
+    let mut packages = vec![];
+
+    // Set by Homebrew/brew bundle - a comma-separated list of all
+    // dependencies in the recursive tree calculated from the dependencies
+    // in the Brewfile.
+    if let Some(formulastring) = environment.get("HOMEBREW_DEPENDENCIES") {
+        // Set by Homebrew/brew bundle - the path to Homebrew's "opt"
+        // directory, which is where links to the private cellar of every
+        // installed package lives.
+        // Usually /opt/homebrew/opt or /usr/local/opt.
+        if let Some(opt_prefix) = environment.get("HOMEBREW_OPT") {
+            for dep in formulastring.split(',') {
+                // Unwrap here is safe because `split` will always return
+                // a collection of at least one item.
+                let short_name = dep.split('/').last().unwrap();
+                let pkg_opt = format!("{opt_prefix}/{short_name}");
+                packages.push((dep.to_owned(), pkg_opt));
+            }
+        }
+    }
+
+    packages
+}
+
+/// Takes a BTreeMap of key/value environment variables produced by
+/// `brew bundle exec` and decides which ones we want to keep for our own builds.
+/// Returns a Vec containing (KEY, value) tuples.
+pub fn select_brew_env(environment: &SortedMap<&str, &str>) -> Vec<(String, String)> {
+    let mut desired_env = vec![];
+
+    // Several of Homebrew's environment variables are safe for us to use
+    // unconditionally, so pick those in their entirety.
+    if let Some(value) = environment.get("PKG_CONFIG_PATH") {
+        desired_env.push(("PKG_CONFIG_PATH".to_owned(), value.to_string()))
+    }
+    if let Some(value) = environment.get("PKG_CONFIG_LIBDIR") {
+        desired_env.push(("PKG_CONFIG_LIBDIR".to_owned(), value.to_string()))
+    }
+    if let Some(value) = environment.get("CMAKE_INCLUDE_PATH") {
+        desired_env.push(("CMAKE_INCLUDE_PATH".to_owned(), value.to_string()))
+    }
+    if let Some(value) = environment.get("CMAKE_LIBRARY_PATH") {
+        desired_env.push(("CMAKE_LIBRARY_PATH".to_owned(), value.to_string()))
+    }
+    let mut paths = vec![];
+
+    // For each listed dependency, add it to the PATH
+    for (_, pkg_opt) in formulas_from_env(environment) {
+        // Not every package will have a /bin or /sbin directory,
+        // but it's safe to add both to the PATH just in case.
+        paths.push(format!("{pkg_opt}/bin"));
+        paths.push(format!("{pkg_opt}/sbin"));
+    }
+
+    if !paths.is_empty() {
+        let our_path = env!("PATH");
+        let desired_path = format!("{our_path}:{}", paths.join(":"));
+
+        desired_env.insert(0, ("PATH".to_owned(), desired_path));
+    }
+
+    desired_env
+}
+
+/// Determines the flags needed by the linker to link against
+/// Homebrew packages in the provided environment.
+/// Note that this may reference directories which don't exist;
+/// this function doesn't validate the existence of directories in the
+/// generated flags.
+pub fn calculate_ldflags(environment: &SortedMap<&str, &str>) -> String {
+    formulas_from_env(environment)
+        .iter()
+        .map(|(_, pkg_opt)| format!("-L{pkg_opt}/lib"))
+        .collect::<Vec<String>>()
+        .join(" ")
+}
+
+/// Determines the flags needed by the compiler to locate headers
+/// from Homebrew packages in the provided environment.
+/// Note that this may reference directories which don't exist;
+/// this function doesn't validate the existence of directories in the
+/// generated flags.
+pub fn calculate_cflags(environment: &SortedMap<&str, &str>) -> String {
+    formulas_from_env(environment)
+        .iter()
+        .map(|(_, pkg_opt)| format!("-I{pkg_opt}/include"))
+        .collect::<Vec<String>>()
+        .join(" ")
+}
diff --git a/cargo-dist/src/errors.rs b/cargo-dist/src/errors.rs
index b7dbb879..913de76c 100644
--- a/cargo-dist/src/errors.rs
+++ b/cargo-dist/src/errors.rs
@@ -248,6 +248,14 @@ pub enum DistError {
     /// random axotag error
     #[error(transparent)]
     AxotagError(#[from] axotag::errors::TagError),
+
+    /// No workspace found from axoproject
+    #[error("No workspace found; either your project doesn't have a Cargo.toml/dist.toml, or we couldn't read it")]
+    ProjectMissing {
+        /// axoproject's error for the unidentified project
+        #[related]
+        sources: Vec<AxoprojectError>,
+    },
 }
 
 impl From<minijinja::Error> for DistError {
diff --git a/cargo-dist/src/generic_build.rs b/cargo-dist/src/generic_build.rs
new file mode 100644
index 00000000..b17c4b89
--- /dev/null
+++ b/cargo-dist/src/generic_build.rs
@@ -0,0 +1,117 @@
+//! Functionality required to invoke a generic build's `build-command`
+
+use std::{env, process::Command};
+
+use camino::Utf8Path;
+use miette::{miette, Context, IntoDiagnostic};
+use tracing::info;
+
+use crate::{
+    copy_file,
+    env::{calculate_cflags, calculate_ldflags, fetch_brew_env, parse_env, select_brew_env},
+    errors::Result,
+    BinaryIdx, BuildStep, DistGraph, DistGraphBuilder, GenericBuildStep, SortedMap, TargetTriple,
+};
+
+impl<'a> DistGraphBuilder<'a> {
+    pub(crate) fn compute_generic_builds(&mut self) -> Vec<BuildStep> {
+        let mut targets = SortedMap::<TargetTriple, Vec<BinaryIdx>>::new();
+        for (binary_idx, binary) in self.inner.binaries.iter().enumerate() {
+            targets
+                .entry(binary.target.clone())
+                .or_default()
+                .push(BinaryIdx(binary_idx));
+        }
+
+        let mut builds = vec![];
+        for (target, binaries) in targets {
+            builds.push(BuildStep::Generic(GenericBuildStep {
+                target_triple: target.clone(),
+                expected_binaries: binaries,
+                build_command: self
+                    .workspace
+                    .build_command
+                    .clone()
+                    .expect("A build command is mandatory for generic builds"),
+            }));
+        }
+
+        builds
+    }
+}
+
+/// Build a generic target
+pub fn build_generic_target(dist_graph: &DistGraph, target: &GenericBuildStep) -> Result<()> {
+    let mut command_string = target.build_command.clone();
+    eprintln!(
+        "building generic target ({} via {})",
+        target.target_triple,
+        command_string.join(" ")
+    );
+
+    let mut desired_extra_env = vec![];
+    let mut cflags = None;
+    let mut ldflags = None;
+    let skip_brewfile = env::var("DO_NOT_USE_BREWFILE").is_ok();
+    if !skip_brewfile {
+        if let Some(env_output) = fetch_brew_env(dist_graph)? {
+            let brew_env = parse_env(&env_output)?;
+            desired_extra_env = select_brew_env(&brew_env);
+            cflags = Some(calculate_cflags(&brew_env));
+            ldflags = Some(calculate_ldflags(&brew_env));
+        }
+    }
+
+    let args = command_string.split_off(1);
+    let mut command = Command::new(
+        command_string
+            .first()
+            .expect("The build command must contain at least one entry"),
+    );
+    for arg in args {
+        command.arg(arg);
+    }
+    // If we generated any extra environment variables to
+    // inject into the environment, apply them now.
+    command.envs(desired_extra_env);
+
+    // Ensure we inform the build what architecture and platform
+    // it's building for.
+    command.env("CARGO_DIST_TARGET", &target.target_triple);
+
+    // Pass CFLAGS/LDFLAGS for C builds
+    if let Some(cflags) = cflags {
+        // These typically contain the same values as each other.
+        // Properly speaking, CPPFLAGS is for C++ software and CFLAGS is for
+        // C software, but many buildsystems treat them as interchangeable.
+        command.env("CFLAGS", &cflags);
+        command.env("CPPFLAGS", &cflags);
+    }
+    if let Some(ldflags) = ldflags {
+        command.env("LDFLAGS", &ldflags);
+    }
+
+    info!("exec: {:?}", command);
+    command
+        .output()
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to exec generic build: {command:?}"))?;
+
+    // Check that we got everything we expected, and normalize to ArtifactIdx => Artifact Path
+    for binary_idx in &target.expected_binaries {
+        let binary = dist_graph.binary(*binary_idx);
+        let binary_path = Utf8Path::new(&binary.file_name);
+        if binary_path.exists() {
+            for dest in &binary.copy_exe_to {
+                copy_file(binary_path, dest)?;
+            }
+        } else {
+            return Err(miette!(
+                "failed to find bin {} -- did the build above have errors?",
+                binary_path
+            ));
+        }
+    }
+
+    Ok(())
+}
diff --git a/cargo-dist/src/init.rs b/cargo-dist/src/init.rs
index 12d085c8..0ce1f1df 100644
--- a/cargo-dist/src/init.rs
+++ b/cargo-dist/src/init.rs
@@ -1,6 +1,6 @@
 use axoasset::toml_edit;
-use axoproject::WorkspaceInfo;
 use axoproject::{errors::AxoprojectError, platforms::triple_to_display_name};
+use axoproject::{WorkspaceInfo, WorkspaceKind};
 use camino::Utf8PathBuf;
 use cargo_dist_schema::PrRunMode;
 use semver::Version;
@@ -51,7 +51,11 @@ pub fn do_init(cfg: &Config, args: &InitArgs) -> Result<()> {
     let check = console::style("✔".to_string()).for_stderr().green();
 
     // Init things
-    let did_add_profile = init_dist_profile(cfg, &mut workspace_toml)?;
+    let did_add_profile = if workspace.kind == WorkspaceKind::Rust {
+        init_dist_profile(cfg, &mut workspace_toml)?
+    } else {
+        false
+    };
 
     eprintln!("let's setup your cargo-dist config...");
     eprintln!();
@@ -71,7 +75,13 @@ pub fn do_init(cfg: &Config, args: &InitArgs) -> Result<()> {
     };
 
     if let Some(meta) = &multi_meta.workspace {
-        let metadata = config::get_toml_metadata(&mut workspace_toml, true);
+        let metadata = if workspace.kind == WorkspaceKind::Rust {
+            // Write to metadata table
+            config::get_toml_metadata(&mut workspace_toml, true)
+        } else {
+            // Write to document root
+            workspace_toml.as_item_mut()
+        };
         apply_dist_to_metadata(metadata, meta);
     }
 
@@ -171,6 +181,25 @@ fn init_dist_profile(_cfg: &Config, workspace_toml: &mut toml_edit::Document) ->
     Ok(true)
 }
 
+fn has_metadata_table(workspace_info: &WorkspaceInfo) -> bool {
+    if workspace_info.kind == WorkspaceKind::Rust {
+        // Setup [workspace.metadata.dist]
+        workspace_info
+            .cargo_metadata_table
+            .as_ref()
+            .and_then(|t| t.as_object())
+            .map(|t| t.contains_key(METADATA_DIST))
+            .unwrap_or(false)
+    } else {
+        config::parse_metadata_table_or_manifest(
+            workspace_info.kind,
+            &workspace_info.manifest_path,
+            workspace_info.cargo_metadata_table.as_ref(),
+        )
+        .is_ok()
+    }
+}
+
 /// Initialize [workspace.metadata.dist] with default values based on what was passed on the CLI
 ///
 /// Returns whether the initialization was actually done
@@ -181,15 +210,11 @@ fn get_new_dist_metadata(
     workspace_info: &WorkspaceInfo,
 ) -> DistResult<DistMetadata> {
     use dialoguer::{Confirm, Input, MultiSelect, Select};
-    // Setup [workspace.metadata.dist]
-    let has_config = workspace_info
-        .cargo_metadata_table
-        .as_ref()
-        .and_then(|t| t.as_object())
-        .map(|t| t.contains_key(METADATA_DIST))
-        .unwrap_or(false);
+    let has_config = has_metadata_table(workspace_info);
+
     let mut meta = if has_config {
-        config::parse_metadata_table(
+        config::parse_metadata_table_or_manifest(
+            workspace_info.kind,
             &workspace_info.manifest_path,
             workspace_info.cargo_metadata_table.as_ref(),
         )?
diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs
index 27cd6a66..88ac2b2d 100644
--- a/cargo-dist/src/lib.rs
+++ b/cargo-dist/src/lib.rs
@@ -21,6 +21,7 @@ use cargo_dist_schema::DistManifest;
 use config::{
     ArtifactMode, ChecksumStyle, CompressionImpl, Config, DirtyMode, GenerateMode, ZipStyle,
 };
+use generic_build::build_generic_target;
 use semver::Version;
 use tracing::info;
 
@@ -33,7 +34,9 @@ pub mod announce;
 pub mod backend;
 pub mod cargo_build;
 pub mod config;
+pub mod env;
 pub mod errors;
+pub mod generic_build;
 mod init;
 pub mod linkage;
 pub mod manifest;
@@ -96,6 +99,7 @@ fn run_build_step(
     manifest: &DistManifest,
 ) -> Result<()> {
     match target {
+        BuildStep::Generic(target) => build_generic_target(dist_graph, target),
         BuildStep::Cargo(target) => build_cargo_target(dist_graph, target),
         BuildStep::Rustup(cmd) => rustup_toolchain(dist_graph, cmd),
         BuildStep::CopyFile(CopyStep {
diff --git a/cargo-dist/src/tasks.rs b/cargo-dist/src/tasks.rs
index c77008d9..bf872831 100644
--- a/cargo-dist/src/tasks.rs
+++ b/cargo-dist/src/tasks.rs
@@ -48,12 +48,13 @@
 //! Also note that the BuildSteps for installers are basically monolithic "build that installer"
 //! steps to give them the freedom to do whatever they need to do.
 
+use std::process::Command;
+
 use axoproject::{PackageId, PackageIdx, WorkspaceInfo};
 use camino::Utf8PathBuf;
 use cargo_dist_schema::{DistManifest, Hosting};
 use miette::{miette, Context, IntoDiagnostic};
 use semver::Version;
-use std::process::Command;
 use tracing::{info, warn};
 
 use crate::announce::{self, AnnouncementTag};
@@ -264,6 +265,8 @@ pub struct Binary {
 #[derive(Debug)]
 #[allow(clippy::large_enum_variant)]
 pub enum BuildStep {
+    /// Do a generic build (and copy the outputs to various locations)
+    Generic(GenericBuildStep),
     /// Do a cargo build (and copy the outputs to various locations)
     Cargo(CargoBuildStep),
     /// Run rustup to get a toolchain
@@ -301,6 +304,17 @@ pub struct CargoBuildStep {
     pub expected_binaries: Vec<BinaryIdx>,
 }
 
+/// A cargo build (and copy the outputs to various locations)
+#[derive(Debug)]
+pub struct GenericBuildStep {
+    /// The --target triple to pass
+    pub target_triple: TargetTriple,
+    /// Binaries we expect from this build
+    pub expected_binaries: Vec<BinaryIdx>,
+    /// The command to run to produce the expected binaries
+    pub build_command: Vec<String>,
+}
+
 /// A cargo build (and copy the outputs to various locations)
 #[derive(Debug)]
 pub struct RustupStep {
@@ -583,12 +597,14 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         let workspace_dir = workspace.workspace_dir.clone();
         let dist_dir = target_dir.join(TARGET_DIST);
 
-        // Read the global config
-        let dist_profile = workspace.cargo_profiles.get(PROFILE_DIST);
-        let mut workspace_metadata = config::parse_metadata_table(
-            &workspace.manifest_path,
-            workspace.cargo_metadata_table.as_ref(),
-        )?;
+        let mut workspace_metadata =
+            // Read the global config
+            config::parse_metadata_table_or_manifest(
+                workspace.kind,
+                &workspace.manifest_path,
+                workspace.cargo_metadata_table.as_ref(),
+            )?;
+
         workspace_metadata.make_relative_to(&workspace.workspace_dir);
 
         // This is intentionally written awkwardly to make you update this
@@ -728,7 +744,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
 
         Ok(Self {
             inner: DistGraph {
-                is_init: dist_profile.is_some(),
+                is_init: desired_cargo_dist_version.is_some(),
                 target_dir,
                 workspace_dir,
                 dist_dir,
@@ -791,7 +807,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         let package_info = self.workspace().package(pkg_idx);
         let package_config = self.package_metadata(pkg_idx);
 
-        let version = package_info.version.as_ref().unwrap().cargo().clone();
+        let version = package_info.version.as_ref().unwrap().semver().clone();
         let app_name = package_info.name.clone();
         let app_desc = package_info.description.clone();
         let app_authors = package_info.authors.clone();
@@ -894,7 +910,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 .version
                 .as_ref()
                 .expect("Package version is mandatory!")
-                .cargo();
+                .semver();
             let pkg_id = package.cargo_package_id.clone();
             // For now we just use the name of the package as its package_spec.
             // I'm not sure if there are situations where this is ambiguous when
@@ -1847,8 +1863,11 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
 
         let mut local_build_steps = vec![];
         let mut global_build_steps = vec![];
-        let cargo_builds = self.compute_cargo_builds();
-        local_build_steps.extend(cargo_builds);
+        let builds = match self.workspace.kind {
+            axoproject::WorkspaceKind::Generic => self.compute_generic_builds(),
+            axoproject::WorkspaceKind::Rust => self.compute_cargo_builds(),
+        };
+        local_build_steps.extend(builds);
 
         Self::add_build_steps_for_artifacts(
             &self


Test patch:
diff --git a/cargo-dist/src/tests/mock.rs b/cargo-dist/src/tests/mock.rs
index 0d2a5c74..d5751417 100644
--- a/cargo-dist/src/tests/mock.rs
+++ b/cargo-dist/src/tests/mock.rs
@@ -88,6 +88,7 @@ pub fn mock_workspace(packages: Vec<PackageInfo>) -> WorkspaceInfo {
             changelog: None,
         },
         warnings: vec![],
+        build_command: None,
         cargo_metadata_table: None,
         cargo_profiles: Default::default(),
     }