Repository: axodotdev/cargo-dist
Pull request #546 (2023-11-01 20:41:04)

chore: refactor codebase for artifact hosting
* new Hosting type added to dist-manifest
* dist-manifest merging now generalized
* tasks.rs and lib.rs broken up into more files
* serde annotations cleaned up


Patch:
diff --git a/cargo-dist-schema/src/lib.rs b/cargo-dist-schema/src/lib.rs
index 8bf7d458..c5483d5e 100644
--- a/cargo-dist-schema/src/lib.rs
+++ b/cargo-dist-schema/src/lib.rs
@@ -74,6 +74,10 @@ pub struct DistManifest {
     pub ci: Option<CiInfo>,
     /// Data about dynamic linkage in the built libraries
     pub linkage: Vec<Linkage>,
+    /// Hosting info
+    #[serde(default)]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub hosting: Option<Hosting>,
 }
 
 /// CI backend info
@@ -362,6 +366,7 @@ impl DistManifest {
             publish_prereleases: false,
             ci: None,
             linkage: vec![],
+            hosting: None,
         }
     }
 
@@ -391,6 +396,11 @@ impl DistManifest {
             .iter()
             .filter_map(|k| Some((&**k, self.artifacts.get(k)?)))
     }
+
+    /// Get the base URL that artifacts should be downloaded from (append the artifact name to the URL)
+    pub fn artifact_download_url(&self) -> Option<&str> {
+        self.hosting.as_ref()?.live_artifacts_url.as_deref()
+    }
 }
 
 /// Information about dynamic libraries used by a binary
@@ -422,6 +432,21 @@ pub struct Library {
     pub source: Option<String>,
 }
 
+/// Information about how artifacts are hosted
+#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
+pub struct Hosting {
+    /// A URL you can GET uploaded artifacts from while the release is staged
+    pub staging_artifacts_url: Option<String>,
+    /// A URL you can GET uploaded artifacts from when it's published
+    pub live_artifacts_url: Option<String>,
+    /// A URL you can PUT (upload) artifacts to
+    pub upload_url: Option<String>,
+    /// A URL you can PUT, transitions the release from staged to published
+    pub publish_url: Option<String>,
+    /// A URL you can PUT, announces the release
+    pub announce_url: Option<String>,
+}
+
 /// Helper to read the raw version from serialized json
 fn dist_version(input: &str) -> Option<Version> {
     #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
diff --git a/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap b/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
index 7c7555c8..f858dd49 100644
--- a/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
+++ b/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
@@ -69,6 +69,17 @@ expression: json_schema
         "null"
       ]
     },
+    "hosting": {
+      "description": "Hosting info",
+      "anyOf": [
+        {
+          "$ref": "#/definitions/Hosting"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    },
     "linkage": {
       "description": "Data about dynamic linkage in the built libraries",
       "type": "array",
@@ -439,6 +450,47 @@ expression: json_schema
         }
       }
     },
+    "Hosting": {
+      "description": "Information about how artifacts are hosted",
+      "type": "object",
+      "properties": {
+        "announce_url": {
+          "description": "A URL you can PUT, announces the release",
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "live_artifacts_url": {
+          "description": "A URL you can GET uploaded artifacts from when it's published",
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "publish_url": {
+          "description": "A URL you can PUT, transitions the release from staged to published",
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "staging_artifacts_url": {
+          "description": "A URL you can GET uploaded artifacts from while the release is staged",
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "upload_url": {
+          "description": "A URL you can PUT (upload) artifacts to",
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
     "Library": {
       "description": "Represents a dynamic library located somewhere on the system",
       "type": "object",
diff --git a/cargo-dist/src/announce.rs b/cargo-dist/src/announce.rs
new file mode 100644
index 00000000..e0427278
--- /dev/null
+++ b/cargo-dist/src/announce.rs
@@ -0,0 +1,614 @@
+//! Computing the Announcement
+//!
+//! This is both "selection of what we're announcing via the tag" and "changelog stuff"
+
+use axoproject::platforms::triple_to_display_name;
+use axoproject::PackageIdx;
+use itertools::Itertools;
+use semver::Version;
+use tracing::{info, warn};
+
+use crate::{
+    backend::installer::{homebrew::HomebrewInstallerInfo, npm::NpmInstallerInfo, InstallerImpl},
+    config::CiStyle,
+    errors::{DistError, DistResult},
+    ArtifactKind, DistGraphBuilder, SortedMap,
+};
+
+/// details on what we're announcing
+pub(crate) struct AnnouncementTag {
+    /// The full tag
+    pub tag: String,
+    /// The version we're announcing (if doing a unified version announcement)
+    pub version: Option<Version>,
+    /// The package we're announcing (if doing a single-package announcement)
+    pub package: Option<PackageIdx>,
+    /// whether we're prereleasing
+    pub prerelease: bool,
+    /// Which packages+bins we're announcing
+    pub rust_releases: Vec<(PackageIdx, Vec<String>)>,
+}
+
+/// details on what we're announcing (partially computed)
+struct PartialAnnouncementTag {
+    /// The full tag
+    pub tag: Option<String>,
+    /// The version we're announcing (if doing a unified version announcement)
+    pub version: Option<Version>,
+    /// The package we're announcing (if doing a single-package announcement)
+    pub package: Option<PackageIdx>,
+    /// whether we're prereleasing
+    pub prerelease: bool,
+}
+
+impl<'a> DistGraphBuilder<'a> {
+    pub(crate) fn compute_announcement_info(&mut self, announcing: &AnnouncementTag) {
+        // Default to using the tag as a title
+        self.manifest.announcement_title = Some(announcing.tag.clone());
+        self.manifest.announcement_tag = Some(announcing.tag.clone());
+        self.manifest.announcement_is_prerelease = announcing.prerelease;
+
+        // Refine the answers
+        self.compute_announcement_changelog(announcing);
+        self.compute_announcement_github();
+    }
+
+    /// Try to compute changelogs for the announcement
+    pub fn compute_announcement_changelog(&mut self, announcing: &AnnouncementTag) {
+        let info = if let Some(announcing_version) = &announcing.version {
+            // Try to find the version we're announcing in the top level CHANGELOG/RELEASES
+            let version = axoproject::Version::Cargo(announcing_version.clone());
+            let Ok(Some(info)) = self.workspace.changelog_for_version(&version) else {
+                info!(
+                    "failed to find {version} in workspace changelogs, skipping changelog generation"
+                );
+                return;
+            };
+
+            info
+        } else if let Some(announcing_package) = announcing.package {
+            // Try to find the package's specific CHANGELOG/RELEASES
+            let package = self.workspace.package(announcing_package);
+            let package_name = &package.name;
+            let version = package
+                .version
+                .as_ref()
+                .expect("cargo package without a version!?");
+            let Ok(Some(info)) = self
+                .workspace
+                .package(announcing_package)
+                .changelog_for_version(version)
+            else {
+                info!(
+                    "failed to find {version} in {package_name} changelogs, skipping changelog generation"
+                );
+                return;
+            };
+
+            info
+        } else {
+            unreachable!("you're neither announcing a version or a package!?");
+        };
+
+        info!("successfully parsed changelog!");
+        self.manifest.announcement_title = Some(info.title);
+        // Those windows newlines get everywhere...
+        let clean_notes = newline_converter::dos2unix(&info.body);
+        self.manifest.announcement_changelog = Some(clean_notes.into_owned());
+    }
+
+    /// If we're publishing to Github, generate some Github notes
+    fn compute_announcement_github(&mut self) {
+        use std::fmt::Write;
+
+        if !self.inner.ci_style.contains(&CiStyle::Github) {
+            info!("not publishing to Github, skipping Github Release Notes");
+            return;
+        }
+
+        let mut gh_body = String::new();
+        let download_url = self.manifest.artifact_download_url();
+
+        // add release notes
+        if let Some(changelog) = self.manifest.announcement_changelog.as_ref() {
+            gh_body.push_str("## Release Notes\n\n");
+            gh_body.push_str(changelog);
+            gh_body.push_str("\n\n");
+        }
+
+        // Add the contents of each Release to the body
+        for release in &self.inner.releases {
+            let heading_suffix = format!("{} {}", release.app_name, release.version);
+
+            // Delineate releases if there's more than 1
+            if self.inner.releases.len() > 1 {
+                writeln!(gh_body, "# {heading_suffix}\n").unwrap();
+            }
+
+            // Sort out all the artifacts in this Release
+            let mut global_installers = vec![];
+            let mut local_installers = vec![];
+            let mut bundles = vec![];
+            let mut symbols = vec![];
+
+            for &artifact_idx in &release.global_artifacts {
+                let artifact = self.artifact(artifact_idx);
+                match &artifact.kind {
+                    ArtifactKind::ExecutableZip(zip) => bundles.push((artifact, zip)),
+                    ArtifactKind::Symbols(syms) => symbols.push((artifact, syms)),
+                    ArtifactKind::Checksum(_) => {}
+                    ArtifactKind::Installer(installer) => {
+                        global_installers.push((artifact, installer))
+                    }
+                }
+            }
+
+            for &variant_idx in &release.variants {
+                let variant = self.variant(variant_idx);
+                for &artifact_idx in &variant.local_artifacts {
+                    let artifact = self.artifact(artifact_idx);
+                    match &artifact.kind {
+                        ArtifactKind::ExecutableZip(zip) => bundles.push((artifact, zip)),
+                        ArtifactKind::Symbols(syms) => symbols.push((artifact, syms)),
+                        ArtifactKind::Checksum(_) => {}
+                        ArtifactKind::Installer(installer) => {
+                            local_installers.push((artifact, installer))
+                        }
+                    }
+                }
+            }
+
+            if !global_installers.is_empty() {
+                writeln!(gh_body, "## Install {heading_suffix}\n").unwrap();
+                for (_installer, details) in global_installers {
+                    let info = match details {
+                        InstallerImpl::Shell(info)
+                        | InstallerImpl::Homebrew(HomebrewInstallerInfo { inner: info, .. })
+                        | InstallerImpl::Powershell(info)
+                        | InstallerImpl::Npm(NpmInstallerInfo { inner: info, .. }) => info,
+                        InstallerImpl::Msi(_) => {
+                            // Should be unreachable, but let's not crash over it
+                            continue;
+                        }
+                    };
+                    writeln!(&mut gh_body, "### {}\n", info.desc).unwrap();
+                    writeln!(&mut gh_body, "```sh\n{}\n```\n", info.hint).unwrap();
+                }
+            }
+
+            let other_artifacts: Vec<_> = bundles
+                .iter()
+                .map(|i| i.0)
+                .chain(local_installers.iter().map(|i| i.0))
+                .chain(symbols.iter().map(|i| i.0))
+                .collect();
+            if !other_artifacts.is_empty() && download_url.is_some() {
+                let download_url = download_url.as_ref().unwrap();
+                writeln!(gh_body, "## Download {heading_suffix}\n",).unwrap();
+                gh_body.push_str("|  File  | Platform | Checksum |\n");
+                gh_body.push_str("|--------|----------|----------|\n");
+
+                for artifact in other_artifacts {
+                    let mut targets = String::new();
+                    let mut multi_target = false;
+                    for target in &artifact.target_triples {
+                        if multi_target {
+                            targets.push_str(", ");
+                        }
+                        targets.push_str(target);
+                        multi_target = true;
+                    }
+                    let name = &artifact.id;
+                    let artifact_download_url = format!("{download_url}/{name}");
+                    let download = format!("[{name}]({artifact_download_url})");
+                    let checksum = if let Some(checksum_idx) = artifact.checksum {
+                        let checksum_name = &self.artifact(checksum_idx).id;
+                        let checksum_download_url = format!("{download_url}/{checksum_name}");
+                        format!("[checksum]({checksum_download_url})")
+                    } else {
+                        String::new()
+                    };
+                    let mut triple = artifact
+                        .target_triples
+                        .iter()
+                        .filter_map(|t| triple_to_display_name(t))
+                        .join(", ");
+                    if triple.is_empty() {
+                        triple = "Unknown".to_string();
+                    }
+                    writeln!(&mut gh_body, "| {download} | {triple} | {checksum} |").unwrap();
+                }
+                writeln!(&mut gh_body).unwrap();
+            }
+        }
+
+        info!("successfully generated github release body!");
+        // self.inner.artifact_download_url = Some(download_url);
+        self.manifest.announcement_github_body = Some(gh_body);
+    }
+}
+
+/// See if we should dist this package.
+///
+/// Some(disabled_reason) is returned if it shouldn't be.
+///
+/// This code is written to assume a package and its binaries should be distable,
+/// and then runs through a battery of disqualifying reasons.
+///
+/// A notable consequence of this is that if --tag wasn't passed, then we will default to
+/// letting through all the packages that aren't intrinsically disqualified by things like
+/// publish=false. Later steps will then check if a coherent announcement tag exists that
+/// covers everything this function spat out.
+fn check_dist_package(
+    graph: &DistGraphBuilder,
+    pkg_id: PackageIdx,
+    pkg: &axoproject::PackageInfo,
+    announcing: &PartialAnnouncementTag,
+) -> Option<String> {
+    // Nothing to publish if there's no binaries!
+    if pkg.binaries.is_empty() {
+        return Some("no binaries".to_owned());
+    }
+
+    // If [metadata.dist].dist is explicitly set, respect it!
+    let override_publish = if let Some(do_dist) = graph.package_metadata(pkg_id).dist {
+        if !do_dist {
+            return Some("dist = false".to_owned());
+        } else {
+            true
+        }
+    } else {
+        false
+    };
+
+    // Otherwise defer to Cargo's `publish = false`
+    if !pkg.publish && !override_publish {
+        return Some("publish = false".to_owned());
+    }
+
+    // If we're announcing a package, reject every other package
+    if let Some(id) = announcing.package {
+        if pkg_id != id {
+            return Some(format!(
+                "didn't match tag {}",
+                announcing.tag.as_ref().unwrap()
+            ));
+        }
+    }
+
+    // If we're announcing a version, ignore everything that doesn't match that
+    if let Some(ver) = &announcing.version {
+        if pkg.version.as_ref().unwrap().cargo() != ver {
+            return Some(format!(
+                "didn't match tag {}",
+                announcing.tag.as_ref().unwrap()
+            ));
+        }
+    }
+
+    // If it passes the guantlet, dist it
+    None
+}
+
+/// Parse the announcement tag and determine what we're announcing
+///
+/// `tag` being None here is equivalent to `--tag` not being passed, and tells us to infer
+/// the tag based on things like "every package has the same version, assume we're
+/// announcing that version".
+///
+/// `needs_coherent_announcement_tag = false` tells us to produce a result even if inference
+/// fails to find a tag that will unambiguously work. This is used by commands like `init`
+/// and `generate` which want to consider "everything" even if the user never actually
+/// could announce everything at once. In this case dummy values will appear in every field
+/// except for `AnnouncementTag::rust_releases` which will contain every distable binary
+/// in the workspace.
+pub(crate) fn select_tag(
+    graph: &DistGraphBuilder,
+    tag: Option<&str>,
+    needs_coherent_announcement_tag: bool,
+) -> DistResult<AnnouncementTag> {
+    // Parse the tag
+    let mut announcing = parse_tag(graph, tag)?;
+    // Select which packages/binaries are available from that tag
+    let rust_releases = select_packages(graph, &announcing);
+
+    // Don't proceed if the conclusions don't make sense
+    if rust_releases.is_empty() {
+        // It's ok for there to be no selected binaries if the user explicitly requested an
+        // announcement for a library with `--tag=my-lib-1.0.0`
+        if announcing.package.is_some() {
+            warn!("You're trying to explicitly Release a library, only minimal functionality will work");
+        } else {
+            // No binaries were selected, and they weren't trying to announce a library,
+            // we've gotta bail out, this is too weird.
+            //
+            // To get better help messages, we explore a hypothetical world where they didn't pass
+            // `--tag` so we can get all the options for a good help message.
+            let announcing = parse_tag(graph, None)?;
+            let rust_releases = select_packages(graph, &announcing);
+            let versions = possible_tags(graph, rust_releases.iter().map(|(idx, _)| *idx));
+            let help = tag_help(graph, versions, "You may need to pass the current version as --tag, or need to give all your packages the same version");
+            return Err(DistError::NothingToRelease { help });
+        }
+    }
+
+    // If we don't have a tag yet we MUST successfully select one here or fail
+    if announcing.tag.is_none() {
+        // Group distable packages by version, if there's only one then use that as the tag
+        let versions = possible_tags(graph, rust_releases.iter().map(|(idx, _)| *idx));
+        if versions.len() == 1 {
+            // Nice, one version, use it
+            let version = *versions.first_key_value().unwrap().0;
+            let tag = format!("v{version}");
+            info!("inferred Announcement tag: {}", tag);
+            announcing.tag = Some(tag);
+            announcing.prerelease = !version.pre.is_empty();
+            announcing.version = Some(version.clone());
+        } else if needs_coherent_announcement_tag {
+            // More than one version, give the user some suggestions
+            let help = tag_help(
+                graph,
+                versions,
+                "Please either specify --tag, or give them all the same version",
+            );
+            return Err(DistError::TooManyUnrelatedApps { help });
+        } else {
+            // Ok we got more than one version but we're being run by a command
+            // like `init` or `generate` which just wants us to hand it everything
+            // and doesn't care about coherent announcements. So use a fake tag
+            // and hand it the fully unconstrained list of rust_releases.
+            announcing.tag = Some("v1.0.0-FAKEVER".to_owned());
+            announcing.prerelease = true;
+            announcing.version = Some("1.0.0-FAKEVER".parse().unwrap());
+        }
+    }
+    Ok(AnnouncementTag {
+        tag: announcing
+            .tag
+            .expect("integrity error: failed to select announcement tag"),
+        version: announcing.version,
+        package: announcing.package,
+        prerelease: announcing.prerelease,
+        rust_releases,
+    })
+}
+
+/// Do the actual parsing logic for a tag
+///
+/// If `tag` is None, then we had no --tag to parse, and need to do inference.
+/// The return value is then essentially a default/empty PartialAnnouncementTag
+/// which later passes will fill in.
+fn parse_tag(graph: &DistGraphBuilder, tag: Option<&str>) -> DistResult<PartialAnnouncementTag> {
+    // First thing's first: if they gave us an announcement tag then we should try to parse it
+    let mut announcing_package = None;
+    let mut announcing_version = None;
+    let mut announcing_prerelease = false;
+    let announcement_tag = tag.map(|t| t.to_owned());
+    if let Some(tag) = &announcement_tag {
+        let mut tag_suffix;
+        // Check if we're using `/`'s to delimit things
+        if let Some((prefix, suffix)) = tag.rsplit_once('/') {
+            // We're at least in "blah/v1.0.0" format
+            let maybe_package = if let Some((_prefix, package)) = prefix.rsplit_once('/') {
+                package
+            } else {
+                // There's only one `/`, assume the whole prefix could be a package name
+                prefix
+            };
+            // Check if this is "blah/blah/some-package/v1.0.0" format by checking if the last slash-delimited
+            // component is exactly a package name (strip_prefix produces empty string)
+            if let Some((package, "")) = strip_prefix_package(maybe_package, graph) {
+                announcing_package = Some(package);
+            }
+            tag_suffix = suffix;
+        } else {
+            tag_suffix = tag;
+        };
+
+        // If we don't have an announcing_package yet, check if this is "some-package-v1.0.0" format
+        if announcing_package.is_none() {
+            if let Some((package, suffix)) = strip_prefix_package(tag_suffix, graph) {
+                // Must be followed by a dash to be accepted
+                if let Some(suffix) = suffix.strip_prefix('-') {
+                    tag_suffix = suffix;
+                    announcing_package = Some(package);
+                }
+            }
+        }
+
+        // At this point, assuming the input is valid, tag_suffix should just be the version
+        // component with an optional "v" prefix, so strip that "v"
+        if let Some(suffix) = tag_suffix.strip_prefix('v') {
+            tag_suffix = suffix;
+        }
+
+        // Now parse the version out
+        match tag_suffix.parse::<Version>() {
+            Ok(version) => {
+                // Register whether we're announcing a prerelease
+                announcing_prerelease = !version.pre.is_empty();
+
+                // If there's an announcing package, validate that the version matches
+                if let Some(pkg_idx) = announcing_package {
+                    let package = graph.workspace().package(pkg_idx);
+                    if let Some(real_version) = &package.version {
+                        if real_version.cargo() != &version {
+                            return Err(DistError::ContradictoryTagVersion {
+                                tag: tag.clone(),
+                                package_name: package.name.clone(),
+                                tag_version: version,
+                                real_version: real_version.clone(),
+                            });
+                        }
+                    }
+                } else {
+                    // We had no announcing_package, so looks like we're doing a unified release.
+                    // Set this value to indicate that.
+                    announcing_version = Some(version);
+                }
+            }
+            Err(e) => {
+                return Err(DistError::TagVersionParse {
+                    tag: tag.clone(),
+                    details: e,
+                })
+            }
+        }
+
+        // If none of the approaches work, refuse to proceed
+        if announcing_package.is_none() && announcing_version.is_none() {
+            return Err(DistError::NoTagMatch { tag: tag.clone() });
+        }
+    }
+    Ok(PartialAnnouncementTag {
+        tag: announcement_tag,
+        prerelease: announcing_prerelease,
+        version: announcing_version,
+        package: announcing_package,
+    })
+}
+
+/// Select which packages/binaries the announcement includes and print info about the process
+///
+/// See `check_dist_package` for the actual selection logic and some notes on inference
+/// when `--tag` is absent.
+fn select_packages(
+    graph: &DistGraphBuilder,
+    announcing: &PartialAnnouncementTag,
+) -> Vec<(PackageIdx, Vec<String>)> {
+    info!("");
+    info!("selecting packages from workspace: ");
+    // Choose which binaries we want to release
+    let disabled_sty = console::Style::new().dim();
+    let enabled_sty = console::Style::new();
+    let mut rust_releases = vec![];
+    for (pkg_id, pkg) in graph.workspace().packages() {
+        let pkg_name = &pkg.name;
+
+        // Determine if this package's binaries should be Released
+        let disabled_reason = check_dist_package(graph, pkg_id, pkg, announcing);
+
+        // Report our conclusion/discoveries
+        let sty;
+        if let Some(reason) = &disabled_reason {
+            sty = &disabled_sty;
+            info!("  {}", sty.apply_to(format!("{pkg_name} ({reason})")));
+        } else {
+            sty = &enabled_sty;
+            info!("  {}", sty.apply_to(pkg_name));
+        }
+
+        // Report each binary and potentially add it to the Release for this package
+        let mut rust_binaries = vec![];
+        for binary in &pkg.binaries {
+            info!("    {}", sty.apply_to(format!("[bin] {}", binary)));
+            // In the future might want to allow this to be granular for each binary
+            if disabled_reason.is_none() {
+                rust_binaries.push(binary.to_owned());
+            }
+        }
+
+        // If any binaries were accepted for this package, it's a Release!
+        if !rust_binaries.is_empty() {
+            rust_releases.push((pkg_id, rust_binaries));
+        }
+    }
+    info!("");
+
+    rust_releases
+}
+
+/// Get a list of possible version --tags to use, given a list of packages we want to Announce
+///
+/// This is the set of options used by tag inference. Inference succeeds if
+/// there's only one key in the output.
+fn possible_tags<'a>(
+    graph: &'a DistGraphBuilder,
+    rust_releases: impl IntoIterator<Item = PackageIdx>,
+) -> SortedMap<&'a Version, Vec<PackageIdx>> {
+    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();
+        versions.entry(version).or_default().push(pkg_idx);
+    }
+    versions
+}
+
+/// Get a help printout for what --tags could have been passed
+fn tag_help(
+    graph: &DistGraphBuilder,
+    versions: SortedMap<&Version, Vec<PackageIdx>>,
+    base_suggestion: &str,
+) -> String {
+    use std::fmt::Write;
+    let mut help = String::new();
+
+    let Some(some_pkg) = versions
+        .first_key_value()
+        .and_then(|(_, packages)| packages.first())
+    else {
+        return r#"It appears that you have no packages in your workspace with distable binaries. You can rerun with "--verbose=info" to see what cargo-dist thinks is in your workspace. Here are some typical issues:
+
+    If you're trying to use cargo-dist to announce libraries, we require you explicitly select the library with e.g. "--tag=my-library-v1.0.0", as this mode is experimental.
+
+    If you have binaries in your workspace, `publish = false` could be hiding them and adding "dist = true" to [package.metadata.dist] in your Cargo.toml may help."#.to_owned();
+    };
+
+    help.push_str(base_suggestion);
+    help.push_str("\n\n");
+    help.push_str("Here are some options:\n\n");
+    for (version, packages) in &versions {
+        write!(help, "--tag=v{version} will Announce: ").unwrap();
+        let mut multi_package = false;
+        for &pkg_id in packages {
+            let info = &graph.workspace().package(pkg_id);
+            if multi_package {
+                write!(help, ", ").unwrap();
+            } else {
+                multi_package = true;
+            }
+            write!(help, "{}", info.name).unwrap();
+        }
+        writeln!(help).unwrap();
+    }
+    help.push('\n');
+    let info = &graph.workspace().package(*some_pkg);
+    let some_tag = format!(
+        "--tag={}-v{}",
+        info.name,
+        info.version.as_ref().unwrap().cargo()
+    );
+
+    writeln!(
+        help,
+        "you can also request any single package with {some_tag}"
+    )
+    .unwrap();
+
+    help
+}
+
+/// Try to strip-prefix a package name from the given input, preferring whichever one is longest
+/// (to disambiguate situations where you have `my-app` and `my-app-helper`).
+///
+/// If a match is found, then the return value is:
+/// * the idx of the package
+/// * the rest of the input
+fn strip_prefix_package<'a>(
+    input: &'a str,
+    graph: &DistGraphBuilder,
+) -> Option<(PackageIdx, &'a str)> {
+    let mut result: Option<(PackageIdx, &'a str)> = None;
+    for (pkg_id, package) in graph.workspace().packages() {
+        if let Some(rest) = input.strip_prefix(&package.name) {
+            if let Some((_, best)) = result {
+                if best.len() <= rest.len() {
+                    continue;
+                }
+            }
+            result = Some((pkg_id, rest))
+        }
+    }
+    result
+}
diff --git a/cargo-dist/src/backend/installer/homebrew.rs b/cargo-dist/src/backend/installer/homebrew.rs
index 6dcc344f..4cb3fbe8 100644
--- a/cargo-dist/src/backend/installer/homebrew.rs
+++ b/cargo-dist/src/backend/installer/homebrew.rs
@@ -1,6 +1,6 @@
 //! Code for generating installer.sh
 
-use axoasset::{LocalAsset, SourceFile};
+use axoasset::LocalAsset;
 use camino::Utf8PathBuf;
 use cargo_dist_schema::DistManifest;
 use serde::Serialize;
@@ -47,38 +47,15 @@ pub(crate) fn write_homebrew_formula(
     templates: &Templates,
     graph: &DistGraph,
     source_info: &HomebrewInstallerInfo,
-    manifests: &[DistManifest],
+    manifest: &DistManifest,
 ) -> DistResult<()> {
     let mut info = source_info.clone();
 
-    // Collect all dist-manifests and fetch the appropriate Mac ones
-    let mut manifests = manifests.to_owned();
-    for file in graph.dist_dir.read_dir()? {
-        let path = file?.path();
-        if let Some(filename) = path.file_name() {
-            if !filename.to_string_lossy().ends_with("-dist-manifest.json") {
-                continue;
-            }
-        }
-
-        let json_path = Utf8PathBuf::try_from(path)?;
-        let data = SourceFile::load_local(json_path)?;
-        let manifest: DistManifest = data.deserialize_json()?;
-
-        if manifest.linkage.iter().any(|l| {
-            info.arm64.is_some() && l.target == "aarch64-apple-darwin"
-                || info.x86_64.is_some() && l.target == "x86_64-apple-darwin"
-        }) {
-            manifests.push(manifest);
-        }
-    }
-
     // Fetch any detected dependencies from the linkage data
-    let dependencies = manifests.into_iter().flat_map(|m| {
-        m.linkage
-            .into_iter()
-            .flat_map(|l| l.homebrew.into_iter().filter_map(|lib| lib.source))
-    });
+    let dependencies = manifest
+        .linkage
+        .iter()
+        .flat_map(|l| l.homebrew.iter().filter_map(|lib| lib.source.clone()));
 
     // Merge with the manually-specified deps
     info.dependencies.extend(dependencies);
diff --git a/cargo-dist/src/cargo_build.rs b/cargo-dist/src/cargo_build.rs
new file mode 100644
index 00000000..d71ed814
--- /dev/null
+++ b/cargo-dist/src/cargo_build.rs
@@ -0,0 +1,460 @@
+//! Functionality required to invoke `cargo build` properly
+
+use std::env;
+use std::process::Command;
+
+use camino::{Utf8Path, Utf8PathBuf};
+use miette::{miette, Context, IntoDiagnostic};
+use tracing::{info, warn};
+
+use crate::{
+    copy_file, CargoBuildStep, CargoTargetFeatureList, CargoTargetPackages, DistGraph, FastMap,
+    RustupStep, SortedMap,
+};
+use crate::{errors::*, BinaryIdx, BuildStep, DistGraphBuilder, TargetTriple, PROFILE_DIST};
+
+impl<'a> DistGraphBuilder<'a> {
+    pub(crate) fn compute_cargo_builds(&mut self) -> Vec<BuildStep> {
+        // For now we can be really simplistic and just do a workspace build for every
+        // target-triple we have a binary-that-needs-a-real-build for.
+        let mut targets = SortedMap::<TargetTriple, Vec<BinaryIdx>>::new();
+        for (binary_idx, binary) in self.inner.binaries.iter().enumerate() {
+            if !binary.copy_exe_to.is_empty() || !binary.copy_symbols_to.is_empty() {
+                targets
+                    .entry(binary.target.clone())
+                    .or_default()
+                    .push(BinaryIdx(binary_idx));
+            }
+        }
+
+        let mut builds = vec![];
+        for (target, binaries) in targets {
+            let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
+
+            // FIXME: is there a more principled way for us to add things to RUSTFLAGS
+            // without breaking everything. Cargo has some builtin ways like keys
+            // in [target...] tables that will get "merged" with the flags it wants
+            // to set. More blunt approaches like actually setting the environment
+            // variable I think can result in overwriting flags other places set
+            // (which is defensible, having spaghetti flags randomly injected by
+            // a dozen different tools is a build maintenance nightmare!)
+
+            // You're *supposed* to link libc statically on windows but Rust has a bad default.
+            // See: https://rust-lang.github.io/rfcs/1721-crt-static.html
+            //
+            // ... well ok it's actually more complicated than that. Most rust applications
+            // don't dynamically link anything non-trivial, so statically linking libc is fine.
+            // However if you need to dynamically link stuff there starts to be issues about
+            // agreeing to the crt in play. At that point you should ship a
+            // Visual C(++) Redistributable that installs the version of the runtime you depend
+            // on. Not doing that is basically rolling some dice and hoping the user already
+            // has it installed, which isn't great. We should support redists eventually,
+            // but for now this hacky global flag is here to let you roll dice.
+            if self.inner.msvc_crt_static && target.contains("windows-msvc") {
+                rustflags.push_str(" -Ctarget-feature=+crt-static");
+            }
+
+            // Likewise, the default for musl will change in the future, so
+            // we can future-proof this by adding the flag now
+            // See: https://github.com/axodotdev/cargo-dist/issues/486
+            if target.ends_with("linux-musl") {
+                rustflags.push_str(" -Ctarget-feature=+crt-static -Clink-self-contained=yes");
+            }
+
+            // If we're trying to cross-compile on macOS, ensure the rustup toolchain
+            // is setup!
+            if target.ends_with("apple-darwin")
+                && self.inner.tools.cargo.host_target.ends_with("apple-darwin")
+                && target != self.inner.tools.cargo.host_target
+            {
+                if let Some(rustup) = self.inner.tools.rustup.clone() {
+                    builds.push(BuildStep::Rustup(RustupStep {
+                        rustup,
+                        target: target.clone(),
+                    }));
+                } else {
+                    warn!("You're trying to cross-compile on macOS, but I can't find rustup to ensure you have the rust toolchains for it!")
+                }
+            }
+
+            if target.ends_with("linux-musl")
+                && self.inner.tools.cargo.host_target.ends_with("linux-gnu")
+            {
+                if let Some(rustup) = self.inner.tools.rustup.clone() {
+                    builds.push(BuildStep::Rustup(RustupStep {
+                        rustup,
+                        target: target.clone(),
+                    }));
+                } else {
+                    warn!("You're trying to cross-compile for musl from glibc, but I can't find rustup to ensure you have the rust toolchains for it!")
+                }
+            }
+
+            if self.inner.precise_builds {
+                // `(target, package, features)` uniquely identifies a build we need to do,
+                // so group all the binaries under those buckets and add a build for each one
+                // (targets is handled by the loop we're in)
+                let mut builds_by_pkg_spec = SortedMap::new();
+                for bin_idx in binaries {
+                    let bin = self.binary(bin_idx);
+                    builds_by_pkg_spec
+                        .entry((bin.pkg_spec.clone(), bin.features.clone()))
+                        .or_insert(vec![])
+                        .push(bin_idx);
+                }
+                for ((pkg_spec, features), expected_binaries) in builds_by_pkg_spec {
+                    builds.push(BuildStep::Cargo(CargoBuildStep {
+                        target_triple: target.clone(),
+                        package: CargoTargetPackages::Package(pkg_spec),
+                        features,
+                        rustflags: rustflags.clone(),
+                        profile: String::from(PROFILE_DIST),
+                        expected_binaries,
+                    }));
+                }
+            } else {
+                // If we think a workspace build is possible, every binary agrees on the features, so take an arbitrary one
+                let features = binaries
+                    .first()
+                    .map(|&idx| self.binary(idx).features.clone())
+                    .unwrap_or_default();
+                builds.push(BuildStep::Cargo(CargoBuildStep {
+                    target_triple: target.clone(),
+                    package: CargoTargetPackages::Workspace,
+                    features,
+                    rustflags,
+                    profile: String::from(PROFILE_DIST),
+                    expected_binaries: binaries,
+                }));
+            }
+        }
+        builds
+    }
+}
+
+/// Build a cargo target
+pub fn build_cargo_target(dist_graph: &DistGraph, target: &CargoBuildStep) -> Result<()> {
+    eprint!(
+        "building cargo target ({}/{}",
+        target.target_triple, target.profile
+    );
+
+    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();
+
+            let brew_env = parse_env(&env_output)?;
+            desired_extra_env = select_brew_env(&brew_env);
+            rustflags = determine_brew_rustflags(&rustflags, &brew_env);
+        }
+    }
+
+    let mut command = Command::new(&dist_graph.tools.cargo.cmd);
+    command
+        .arg("build")
+        .arg("--profile")
+        .arg(&target.profile)
+        .arg("--message-format=json-render-diagnostics")
+        .arg("--target")
+        .arg(&target.target_triple)
+        .env("RUSTFLAGS", &rustflags)
+        .stdout(std::process::Stdio::piped());
+    if !target.features.default_features {
+        command.arg("--no-default-features");
+    }
+    match &target.features.features {
+        CargoTargetFeatureList::All => {
+            command.arg("--all-features");
+        }
+        CargoTargetFeatureList::List(features) => {
+            if !features.is_empty() {
+                // The way we pass these, Cargo wants us to use --features
+                // once for each arg, idk why exactly (might be a windows quirk).
+                for feature in features {
+                    command.arg("--features");
+                    command.arg(feature);
+                }
+            }
+        }
+    }
+    match &target.package {
+        CargoTargetPackages::Workspace => {
+            command.arg("--workspace");
+            eprintln!(" --workspace)");
+        }
+        CargoTargetPackages::Package(package) => {
+            command.arg("--package").arg(package);
+            eprintln!(" --package={})", package);
+        }
+    }
+    // If we generated any extra environment variables to
+    // inject into the environment, apply them now.
+    command.envs(desired_extra_env);
+    info!("exec: {:?}", command);
+    let mut task = command
+        .spawn()
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to exec cargo build: {command:?}"))?;
+
+    // Create entries for all the binaries we expect to find, and the paths they should
+    // be copied to (according to the copy_exe_to subscribers list).
+    //
+    // Structure is:
+    //
+    // package-id (key)
+    //    binary-name (key)
+    //       subscribers (list)
+    //          src-path (initially blank, must be filled in by rustc)
+    //          dest-path (where to copy the file to)
+    let mut expected_exes =
+        FastMap::<String, FastMap<String, Vec<(Utf8PathBuf, Utf8PathBuf)>>>::new();
+    let mut expected_symbols =
+        FastMap::<String, FastMap<String, Vec<(Utf8PathBuf, Utf8PathBuf)>>>::new();
+    for &binary_idx in &target.expected_binaries {
+        let binary = &dist_graph.binary(binary_idx);
+        let package_id = binary.pkg_id.to_string();
+        let exe_name = binary.name.clone();
+        for exe_dest in &binary.copy_exe_to {
+            expected_exes
+                .entry(package_id.clone())
+                .or_default()
+                .entry(exe_name.clone())
+                .or_default()
+                .push((Utf8PathBuf::new(), exe_dest.clone()));
+        }
+        for sym_dest in &binary.copy_symbols_to {
+            expected_symbols
+                .entry(package_id.clone())
+                .or_default()
+                .entry(exe_name.clone())
+                .or_default()
+                .push((Utf8PathBuf::new(), sym_dest.clone()));
+        }
+    }
+
+    // Collect up the compiler messages to find out where binaries ended up
+    let reader = std::io::BufReader::new(task.stdout.take().unwrap());
+    for message in cargo_metadata::Message::parse_stream(reader) {
+        let Ok(message) = message
+            .into_diagnostic()
+            .wrap_err("failed to parse cargo json message")
+            .map_err(|e| warn!("{:?}", e))
+        else {
+            // It's ok for there to be messages we don't understand if we don't care about them.
+            // At the end we'll check if we got the messages we *do* need.
+            continue;
+        };
+        match message {
+            cargo_metadata::Message::CompilerArtifact(artifact) => {
+                // Hey we got an executable, is it one we wanted?
+                if let Some(new_exe) = artifact.executable {
+                    info!("got a new exe: {}", new_exe);
+                    let package_id = artifact.package_id.to_string();
+                    let exe_name = new_exe.file_stem().unwrap();
+
+                    // If we expected some symbols, pull them out of the paths of this executable
+                    let expected_sym = expected_symbols
+                        .get_mut(&package_id)
+                        .and_then(|m| m.get_mut(exe_name));
+                    if let Some(expected) = expected_sym {
+                        for (src_sym_path, _) in expected {
+                            for path in &artifact.filenames {
+                                // FIXME: unhardcode this when we add support for other symbol kinds!
+                                let is_symbols =
+                                    path.extension().map(|e| e == "pdb").unwrap_or(false);
+                                if is_symbols {
+                                    // These are symbols we expected! Save the path.
+                                    *src_sym_path = path.to_owned();
+                                }
+                            }
+                        }
+                    }
+
+                    // Get the exe path
+                    let expected_exe = expected_exes
+                        .get_mut(&package_id)
+                        .and_then(|m| m.get_mut(exe_name));
+                    if let Some(expected) = expected_exe {
+                        for (src_bin_path, _) in expected {
+                            // This is an exe we expected! Save the path.
+                            *src_bin_path = new_exe.clone();
+                        }
+                    }
+                }
+            }
+            _ => {
+                // Nothing else interesting?
+            }
+        }
+    }
+
+    // Check that we got everything we expected, and normalize to ArtifactIdx => Artifact Path
+    for (package_id, exes) in expected_exes {
+        for (exe_name, to_copy) in &exes {
+            for (src_path, dest_path) in to_copy {
+                if src_path.as_str().is_empty() {
+                    return Err(miette!(
+                        "failed to find bin {} ({}) -- did the cargo build above have errors?",
+                        exe_name,
+                        package_id
+                    ));
+                }
+                copy_file(src_path, dest_path)?;
+            }
+        }
+    }
+    for (package_id, symbols) in expected_symbols {
+        for (exe, to_copy) in &symbols {
+            for (src_path, dest_path) in to_copy {
+                if src_path.as_str().is_empty() {
+                    return Err(miette!(
+                        "failed to find symbols for bin {} ({}) -- did the cargo build above have errors?",
+                        exe,
+                        package_id
+                    ));
+                }
+                copy_file(src_path, dest_path)?;
+            }
+        }
+    }
+
+    Ok(())
+}
+
+/// Build a cargo target
+pub fn rustup_toolchain(_dist_graph: &DistGraph, cmd: &RustupStep) -> Result<()> {
+    eprintln!("running rustup to ensure you have {} installed", cmd.target);
+    let status = Command::new(&cmd.rustup.cmd)
+        .arg("target")
+        .arg("add")
+        .arg(&cmd.target)
+        .status()
+        .into_diagnostic()
+        .wrap_err("Failed to install rustup toolchain")?;
+
+    if !status.success() {
+        return Err(miette!("Failed to install rustup toolchain"));
+    }
+    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
+}
diff --git a/cargo-dist/src/config.rs b/cargo-dist/src/config.rs
index 07480a45..db103ddd 100644
--- a/cargo-dist/src/config.rs
+++ b/cargo-dist/src/config.rs
@@ -18,6 +18,7 @@ use crate::{
 
 /// Contents of METADATA_DIST in Cargo.toml files
 #[derive(Serialize, Deserialize, Debug, Default, Clone)]
+#[serde(rename_all = "kebab-case")]
 pub struct DistMetadata {
     /// The intended version of cargo-dist to build with. (normal Cargo SemVer syntax)
     ///
@@ -27,14 +28,12 @@ pub struct DistMetadata {
     /// it shouldn't be a problem and newer versions should just be Better... probably you
     /// Really want to have the exact version when running generate to avoid generating
     /// things other cargo-dist versions can't handle!
-    #[serde(rename = "cargo-dist-version")]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub cargo_dist_version: Option<Version>,
 
     /// (deprecated) The intended version of Rust/Cargo to build with (rustup toolchain syntax)
     ///
     /// When generating full tasks graphs (such as CI scripts) we will pick this version.
-    #[serde(rename = "rust-toolchain-version")]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub rust_toolchain_version: Option<String>,
 
@@ -62,12 +61,10 @@ pub struct DistMetadata {
     /// "upload" will build and upload release artifacts, while "plan" will
     /// only plan out the release without running builds and "skip" will disable
     /// pull request runs entirely.
-    #[serde(rename = "pr-run-mode")]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub pr_run_mode: Option<cargo_dist_schema::PrRunMode>,
 
     /// Generate targets whose cargo-dist should avoid checking for up-to-dateness.
-    #[serde(rename = "allow-dirty")]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub allow_dirty: Option<Vec<GenerateMode>>,
 
@@ -124,31 +121,26 @@ pub struct DistMetadata {
     ///
     /// Defaults to true.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "auto-includes")]
     pub auto_includes: Option<bool>,
 
     /// Whether msvc targets should statically link the crt
     ///
     /// Defaults to true.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "msvc-crt-static")]
     pub msvc_crt_static: Option<bool>,
 
     /// The archive format to use for windows builds (defaults .zip)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "windows-archive")]
     pub windows_archive: Option<ZipStyle>,
 
     /// The archive format to use for non-windows builds (defaults .tar.xz)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "unix-archive")]
     pub unix_archive: Option<ZipStyle>,
 
     /// A scope to prefix npm packages with (@ should be included).
     ///
     /// This is required if you're using an npm installer.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "npm-scope")]
     pub npm_scope: Option<String>,
 
     /// A scope to prefix npm packages with (@ should be included).
@@ -180,7 +172,6 @@ pub struct DistMetadata {
     ///
     /// [workspace-hack]: https://docs.rs/cargo-hakari/latest/cargo_hakari/about/index.html
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "precise-builds")]
     pub precise_builds: Option<bool>,
 
     /// Whether we should try to merge otherwise-parallelizable tasks onto the same machine,
@@ -193,7 +184,6 @@ pub struct DistMetadata {
     /// build both of those platforms together on the same machine, making it take twice as long
     /// as any other build and making it impossible for only one of them to succeed.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "merge-tasks")]
     pub merge_tasks: Option<bool>,
 
     /// Whether failing tasks should make us give up on all other tasks
@@ -216,7 +206,6 @@ pub struct DistMetadata {
     /// Prior to 0.1.0 we didn't set the correct flags in our CI scripts to do this, but now we do.
     /// This flag was introduced to allow you to restore the old behaviour if you prefer.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "fail-fast")]
     pub fail_fast: Option<bool>,
 
     /// The strategy to use for selecting a path to install things at:
@@ -229,39 +218,33 @@ pub struct DistMetadata {
     /// All of these error out if the required env-vars aren't set. In the future this may
     /// allow for the input to be an array of options to try in sequence.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "install-path")]
     pub install_path: Option<InstallPathStrategy>,
     /// A list of features to enable when building a package with cargo-dist
     ///
     /// (defaults to none)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "features")]
     pub features: Option<Vec<String>>,
     /// Whether to enable when building a package with cargo-dist
     ///
     /// (defaults to true)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "default-features")]
     pub default_features: Option<bool>,
     /// Whether to enable all features building a package with cargo-dist
     ///
     /// (defaults to false)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "all-features")]
     pub all_features: Option<bool>,
 
     /// Publish jobs to run in CI
     ///
     /// (defaults to none)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "publish-jobs")]
     pub publish_jobs: Option<Vec<PublishStyle>>,
 
     /// Whether to publish prereleases to package managers
     ///
     /// (defaults to false)
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "publish-prereleases")]
     pub publish_prereleases: Option<bool>,
 
     /// Whether we should create the Github Release for you when you push a tag.
@@ -273,12 +256,10 @@ pub struct DistMetadata {
     /// with the title/body you want. At the end of a successful publish it will
     /// undraft the Github Release.
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "create-release")]
     pub create_release: Option<bool>,
 
     /// \[unstable\] Whether we should sign windows binaries with ssl.com
     #[serde(skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "ssldotcom-windows-sign")]
     pub ssldotcom_windows_sign: Option<ProductionMode>,
 }
 
@@ -498,9 +479,9 @@ pub enum ArtifactMode {
 
 /// The style of CI we should generate
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
+#[serde(rename_all = "kebab-case")]
 pub enum CiStyle {
     /// Generate Github CI
-    #[serde(rename = "github")]
     Github,
 }
 
@@ -515,21 +496,17 @@ impl std::fmt::Display for CiStyle {
 
 /// The style of Installer we should generate
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
 pub enum InstallerStyle {
-    /// Generate a shell script that fetches from [`crate::tasks::DistGraph::artifact_download_url`][]
-    #[serde(rename = "shell")]
+    /// Generate a shell script that fetches from [`cargo_dist_schema::DistManifest::artifact_download_url`][]
     Shell,
-    /// Generate a powershell script that fetches from [`crate::tasks::DistGraph::artifact_download_url`][]
-    #[serde(rename = "powershell")]
+    /// Generate a powershell script that fetches from [`cargo_dist_schema::DistManifest::artifact_download_url`][]
     Powershell,
-    /// Generate an npm project that fetches from [`crate::tasks::DistGraph::artifact_download_url`][]
-    #[serde(rename = "npm")]
+    /// Generate an npm project that fetches from [`cargo_dist_schema::DistManifest::artifact_download_url`][]
     Npm,
-    /// Generate a Homebrew formula that fetches from [`crate::tasks::DistGraph::artifact_download_url`][]
-    #[serde(rename = "homebrew")]
+    /// Generate a Homebrew formula that fetches from [`cargo_dist_schema::DistManifest::artifact_download_url`][]
     Homebrew,
     /// Generate an msi installer that embeds the binary
-    #[serde(rename = "msi")]
     Msi,
 }
 
@@ -548,9 +525,9 @@ impl std::fmt::Display for InstallerStyle {
 
 /// The publish jobs we should run
 #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
 pub enum PublishStyle {
     /// Publish a Homebrew formula to a tap repository
-    #[serde(rename = "homebrew")]
     Homebrew,
     /// User-supplied value
     User(String),
@@ -796,15 +773,13 @@ impl InstallPathStrategy {
 
 /// A checksumming algorithm
 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum ChecksumStyle {
     /// sha256sum (using the sha2 crate)
-    #[serde(rename = "sha256")]
     Sha256,
     /// sha512sum (using the sha2 crate)
-    #[serde(rename = "sha512")]
     Sha512,
     /// Do not checksum
-    #[serde(rename = "false")]
     False,
 }
 
@@ -920,12 +895,11 @@ pub enum SystemDependencyKind {
 
 /// Provides detail on when a specific dependency is required
 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum DependencyKind {
     /// A dependency that must be present when the software is being built
-    #[serde(rename = "build")]
     Build,
     /// A dependency that must be present when the software is being used
-    #[serde(rename = "run")]
     Run,
 }
 
@@ -982,12 +956,11 @@ impl DirtyMode {
 
 /// For features that can be generated in "test" or "production" mode
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum ProductionMode {
     /// test mode
-    #[serde(rename = "test")]
     Test,
     /// production mode
-    #[serde(rename = "prod")]
     Prod,
 }
 
diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs
index 046040aa..27cd6a66 100644
--- a/cargo-dist/src/lib.rs
+++ b/cargo-dist/src/lib.rs
@@ -10,41 +10,33 @@
 //! It's currently not terribly well-suited to being used as a pure library because it happily
 //! writes to stderr/stdout whenever it pleases. Suboptimal for a library.
 
-use std::{
-    collections::{BTreeMap, HashMap},
-    env,
-    fs::{self, File},
-    io::{Cursor, Read},
-    process::Command,
-};
-
-use axoasset::{LocalAsset, SourceFile};
+use axoasset::LocalAsset;
 use backend::{
     ci::CiInfo,
-    installer::{self, homebrew::HomebrewInstallerInfo, npm::NpmInstallerInfo, InstallerImpl},
-    templates::{TemplateEntry, TEMPLATE_INSTALLER_NPM},
+    installer::{self, InstallerImpl},
 };
-use camino::{Utf8Path, Utf8PathBuf};
-use cargo_dist_schema::{Asset, AssetKind, DistManifest, ExecutableAsset};
-use comfy_table::{presets::UTF8_FULL, Table};
+use camino::Utf8Path;
+use cargo_build::{build_cargo_target, rustup_toolchain};
+use cargo_dist_schema::DistManifest;
 use config::{
     ArtifactMode, ChecksumStyle, CompressionImpl, Config, DirtyMode, GenerateMode, ZipStyle,
 };
-use goblin::Object;
-use mach_object::{LoadCommand, OFile};
 use semver::Version;
-use serde::{Deserialize, Serialize};
-use tracing::{info, warn};
+use tracing::info;
 
 use errors::*;
 pub use init::{do_init, InitArgs};
-use miette::{miette, Context, IntoDiagnostic};
+use miette::miette;
 pub use tasks::*;
 
+pub mod announce;
 pub mod backend;
+pub mod cargo_build;
 pub mod config;
 pub mod errors;
 mod init;
+pub mod linkage;
+pub mod manifest;
 pub mod tasks;
 #[cfg(test)]
 mod tests;
@@ -53,7 +45,7 @@ mod tests;
 pub fn do_build(cfg: &Config) -> Result<DistManifest> {
     check_integrity(cfg)?;
 
-    let dist = tasks::gather_work(cfg)?;
+    let (dist, mut manifest) = tasks::gather_work(cfg)?;
 
     // FIXME: parallelize this by working this like a dependency graph, so we can start
     // bundling up an executable the moment it's built! Note however that you shouldn't
@@ -75,240 +67,33 @@ pub fn do_build(cfg: &Config) -> Result<DistManifest> {
 
     // Run all the local build steps first
     for step in &dist.local_build_steps {
-        run_build_step(&dist, step, &[])?;
+        run_build_step(&dist, step, &manifest)?;
     }
 
-    // Calculate a temporary build manifest from the output of the local builds
-    // (includes their linkage data)
-    let manifests = vec![build_manifest(cfg, &dist)?];
+    // Compute linkage data now that we're done all builds
+    linkage::add_linkage_to_manifest(cfg, &dist, &mut manifest)?;
 
     // Next the global steps
     for step in &dist.global_build_steps {
-        run_build_step(&dist, step, &manifests)?;
+        run_build_step(&dist, step, &manifest)?;
     }
 
-    Ok(build_manifest(cfg, &dist)?)
+    Ok(manifest)
 }
 
 /// Just generate the manifest produced by `cargo dist build` without building
 pub fn do_manifest(cfg: &Config) -> Result<DistManifest> {
     check_integrity(cfg)?;
-    let dist = gather_work(cfg)?;
-
-    Ok(build_manifest(cfg, &dist)?)
-}
-
-fn build_manifest(cfg: &Config, dist: &DistGraph) -> DistResult<DistManifest> {
-    // Report the releases
-    let mut releases = vec![];
-    let mut all_artifacts = BTreeMap::<String, cargo_dist_schema::Artifact>::new();
-    for release in &dist.releases {
-        // Gather up all the local and global artifacts
-        let mut artifacts = vec![];
-        for &artifact_idx in &release.global_artifacts {
-            let id = &dist.artifact(artifact_idx).id;
-            all_artifacts.insert(id.clone(), manifest_artifact(cfg, dist, artifact_idx));
-            artifacts.push(id.clone());
-        }
-        for &variant_idx in &release.variants {
-            let variant = dist.variant(variant_idx);
-            for &artifact_idx in &variant.local_artifacts {
-                let id = &dist.artifact(artifact_idx).id;
-                all_artifacts.insert(id.clone(), manifest_artifact(cfg, dist, artifact_idx));
-                artifacts.push(id.clone());
-            }
-        }
-
-        // And report the release
-        releases.push(cargo_dist_schema::Release {
-            app_name: release.app_name.clone(),
-            app_version: release.version.to_string(),
-            artifacts,
-        })
-    }
-
-    let linkage = fetch_linkage(
-        cfg.targets.clone(),
-        dist.artifacts.clone(),
-        dist.dist_dir.clone(),
-    )?;
-    let linkage = linkage.iter().map(|l| l.to_schema()).collect();
-
-    let mut manifest = DistManifest::new(releases, all_artifacts);
-
-    // build metadata
-    manifest.dist_version = Some(env!("CARGO_PKG_VERSION").to_owned());
-    manifest.system_info = Some(cargo_dist_schema::SystemInfo {
-        cargo_version_line: dist.tools.cargo.version_line.clone(),
-    });
-
-    // announcement metadata
-    manifest.announcement_tag = dist.announcement_tag.clone();
-    manifest.announcement_is_prerelease = dist.announcement_is_prerelease;
-    manifest.announcement_title = dist.announcement_title.clone();
-    manifest.announcement_changelog = dist.announcement_changelog.clone();
-    manifest.announcement_github_body = dist.announcement_github_body.clone();
-    manifest.system_info = Some(cargo_dist_schema::SystemInfo {
-        cargo_version_line: dist.tools.cargo.version_line.clone(),
-    });
-
-    // ci metadata
-    if !dist.ci_style.is_empty() {
-        let CiInfo { github } = &dist.ci;
-        let github = github.as_ref().map(|info| cargo_dist_schema::GithubCiInfo {
-            artifacts_matrix: Some(info.artifacts_matrix.clone()),
-            pr_run_mode: Some(info.pr_run_mode),
-        });
-
-        manifest.ci = Some(cargo_dist_schema::CiInfo { github });
-    }
-
-    manifest.publish_prereleases = dist.publish_prereleases;
-
-    manifest.linkage = linkage;
+    let (_dist, manifest) = gather_work(cfg)?;
 
     Ok(manifest)
 }
 
-fn manifest_artifact(
-    cfg: &Config,
-    dist: &DistGraph,
-    artifact_idx: ArtifactIdx,
-) -> cargo_dist_schema::Artifact {
-    let artifact = dist.artifact(artifact_idx);
-    let mut assets = vec![];
-
-    let built_assets = artifact
-        .required_binaries
-        .iter()
-        .map(|(&binary_idx, exe_path)| {
-            let binary = &dist.binary(binary_idx);
-            let symbols_artifact = binary.symbols_artifact.map(|a| dist.artifact(a).id.clone());
-            Asset {
-                name: Some(binary.name.clone()),
-                // Always copied to the root... for now
-                path: Some(exe_path.file_name().unwrap().to_owned()),
-                kind: AssetKind::Executable(ExecutableAsset { symbols_artifact }),
-            }
-        });
-
-    let mut static_assets = artifact
-        .archive
-        .as_ref()
-        .map(|archive| {
-            archive
-                .static_assets
-                .iter()
-                .map(|(kind, asset)| {
-                    let kind = match kind {
-                        StaticAssetKind::Changelog => AssetKind::Changelog,
-                        StaticAssetKind::License => AssetKind::License,
-                        StaticAssetKind::Readme => AssetKind::Readme,
-                        StaticAssetKind::Other => AssetKind::Unknown,
-                    };
-                    Asset {
-                        name: Some(asset.file_name().unwrap().to_owned()),
-                        path: Some(asset.file_name().unwrap().to_owned()),
-                        kind,
-                    }
-                })
-                .collect::<Vec<_>>()
-        })
-        .unwrap_or_default();
-
-    // Record the files that we always add to an npm package
-    //
-    // These can't be pre-included in the normal static assets list above because
-    // they're generated from templates, and not copied from the user's project.
-    if let ArtifactKind::Installer(InstallerImpl::Npm(..)) = &artifact.kind {
-        let root_dir = dist
-            .templates
-            .get_template_dir(TEMPLATE_INSTALLER_NPM)
-            .expect("npm template missing!?");
-        let mut queue = vec![root_dir];
-        while let Some(dir) = queue.pop() {
-            for entry in dir.entries.values() {
-                match entry {
-                    TemplateEntry::Dir(dir) => {
-                        queue.push(dir);
-                    }
-                    TemplateEntry::File(file) => {
-                        static_assets.push(Asset {
-                            name: Some(file.name.clone()),
-                            path: Some(file.path_from_ancestor(root_dir).to_string()),
-                            kind: AssetKind::Unknown,
-                        });
-                    }
-                }
-            }
-        }
-    }
-
-    assets.extend(built_assets);
-    assets.extend(static_assets);
-    // Sort the assets by name to make things extra stable
-    assets.sort_by(|k1, k2| k1.name.cmp(&k2.name));
-
-    let install_hint;
-    let description;
-    let kind;
-
-    match &artifact.kind {
-        ArtifactKind::ExecutableZip(_) => {
-            install_hint = None;
-            description = None;
-            kind = cargo_dist_schema::ArtifactKind::ExecutableZip;
-        }
-        ArtifactKind::Symbols(_) => {
-            install_hint = None;
-            description = None;
-            kind = cargo_dist_schema::ArtifactKind::Symbols;
-        }
-        ArtifactKind::Installer(
-            InstallerImpl::Powershell(info)
-            | InstallerImpl::Shell(info)
-            | InstallerImpl::Homebrew(HomebrewInstallerInfo { inner: info, .. })
-            | InstallerImpl::Npm(NpmInstallerInfo { inner: info, .. }),
-        ) => {
-            install_hint = Some(info.hint.clone());
-            description = Some(info.desc.clone());
-            kind = cargo_dist_schema::ArtifactKind::Installer;
-        }
-        ArtifactKind::Installer(InstallerImpl::Msi(..)) => {
-            install_hint = None;
-            description = Some("install via msi".to_owned());
-            kind = cargo_dist_schema::ArtifactKind::Installer;
-        }
-        ArtifactKind::Checksum(_) => {
-            install_hint = None;
-            description = None;
-            kind = cargo_dist_schema::ArtifactKind::Checksum;
-        }
-    };
-
-    let checksum = artifact.checksum.map(|idx| dist.artifact(idx).id.clone());
-
-    cargo_dist_schema::Artifact {
-        name: Some(artifact.id.clone()),
-        path: if cfg.no_local_paths {
-            None
-        } else {
-            Some(artifact.file_path.to_string())
-        },
-        target_triples: artifact.target_triples.clone(),
-        install_hint,
-        description,
-        assets,
-        kind,
-        checksum,
-    }
-}
-
 /// Run some build step
 fn run_build_step(
     dist_graph: &DistGraph,
     target: &BuildStep,
-    manifests: &[DistManifest],
+    manifest: &DistManifest,
 ) -> Result<()> {
     match target {
         BuildStep::Cargo(target) => build_cargo_target(dist_graph, target),
@@ -332,7 +117,7 @@ fn run_build_step(
             with_root,
         }) => zip_dir(src_path, dest_path, zip_style, with_root.as_deref()),
         BuildStep::GenerateInstaller(installer) => {
-            generate_installer(dist_graph, installer, manifests)
+            generate_installer(dist_graph, installer, manifest)
         }
         BuildStep::Checksum(ChecksumImpl {
             checksum,
@@ -400,333 +185,6 @@ fn write_checksum(checksum: &str, src_path: &Utf8Path, dest_path: &Utf8Path) ->
     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<BTreeMap<&str, &str>> {
-    let mut parsed = BTreeMap::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: &BTreeMap<&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: &BTreeMap<&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: &BTreeMap<&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
-}
-
-/// Build a cargo target
-fn build_cargo_target(dist_graph: &DistGraph, target: &CargoBuildStep) -> Result<()> {
-    eprint!(
-        "building cargo target ({}/{}",
-        target.target_triple, target.profile
-    );
-
-    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();
-
-            let brew_env = parse_env(&env_output)?;
-            desired_extra_env = select_brew_env(&brew_env);
-            rustflags = determine_brew_rustflags(&rustflags, &brew_env);
-        }
-    }
-
-    let mut command = Command::new(&dist_graph.tools.cargo.cmd);
-    command
-        .arg("build")
-        .arg("--profile")
-        .arg(&target.profile)
-        .arg("--message-format=json-render-diagnostics")
-        .arg("--target")
-        .arg(&target.target_triple)
-        .env("RUSTFLAGS", &rustflags)
-        .stdout(std::process::Stdio::piped());
-    if !target.features.default_features {
-        command.arg("--no-default-features");
-    }
-    match &target.features.features {
-        CargoTargetFeatureList::All => {
-            command.arg("--all-features");
-        }
-        CargoTargetFeatureList::List(features) => {
-            if !features.is_empty() {
-                // The way we pass these, Cargo wants us to use --features
-                // once for each arg, idk why exactly (might be a windows quirk).
-                for feature in features {
-                    command.arg("--features");
-                    command.arg(feature);
-                }
-            }
-        }
-    }
-    match &target.package {
-        CargoTargetPackages::Workspace => {
-            command.arg("--workspace");
-            eprintln!(" --workspace)");
-        }
-        CargoTargetPackages::Package(package) => {
-            command.arg("--package").arg(package);
-            eprintln!(" --package={})", package);
-        }
-    }
-    // If we generated any extra environment variables to
-    // inject into the environment, apply them now.
-    command.envs(desired_extra_env);
-    info!("exec: {:?}", command);
-    let mut task = command
-        .spawn()
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to exec cargo build: {command:?}"))?;
-
-    // Create entries for all the binaries we expect to find, and the paths they should
-    // be copied to (according to the copy_exe_to subscribers list).
-    //
-    // Structure is:
-    //
-    // package-id (key)
-    //    binary-name (key)
-    //       subscribers (list)
-    //          src-path (initially blank, must be filled in by rustc)
-    //          dest-path (where to copy the file to)
-    let mut expected_exes =
-        HashMap::<String, HashMap<String, Vec<(Utf8PathBuf, Utf8PathBuf)>>>::new();
-    let mut expected_symbols =
-        HashMap::<String, HashMap<String, Vec<(Utf8PathBuf, Utf8PathBuf)>>>::new();
-    for &binary_idx in &target.expected_binaries {
-        let binary = &dist_graph.binary(binary_idx);
-        let package_id = binary.pkg_id.to_string();
-        let exe_name = binary.name.clone();
-        for exe_dest in &binary.copy_exe_to {
-            expected_exes
-                .entry(package_id.clone())
-                .or_default()
-                .entry(exe_name.clone())
-                .or_default()
-                .push((Utf8PathBuf::new(), exe_dest.clone()));
-        }
-        for sym_dest in &binary.copy_symbols_to {
-            expected_symbols
-                .entry(package_id.clone())
-                .or_default()
-                .entry(exe_name.clone())
-                .or_default()
-                .push((Utf8PathBuf::new(), sym_dest.clone()));
-        }
-    }
-
-    // Collect up the compiler messages to find out where binaries ended up
-    let reader = std::io::BufReader::new(task.stdout.take().unwrap());
-    for message in cargo_metadata::Message::parse_stream(reader) {
-        let Ok(message) = message
-            .into_diagnostic()
-            .wrap_err("failed to parse cargo json message")
-            .map_err(|e| warn!("{:?}", e))
-        else {
-            // It's ok for there to be messages we don't understand if we don't care about them.
-            // At the end we'll check if we got the messages we *do* need.
-            continue;
-        };
-        match message {
-            cargo_metadata::Message::CompilerArtifact(artifact) => {
-                // Hey we got an executable, is it one we wanted?
-                if let Some(new_exe) = artifact.executable {
-                    info!("got a new exe: {}", new_exe);
-                    let package_id = artifact.package_id.to_string();
-                    let exe_name = new_exe.file_stem().unwrap();
-
-                    // If we expected some symbols, pull them out of the paths of this executable
-                    let expected_sym = expected_symbols
-                        .get_mut(&package_id)
-                        .and_then(|m| m.get_mut(exe_name));
-                    if let Some(expected) = expected_sym {
-                        for (src_sym_path, _) in expected {
-                            for path in &artifact.filenames {
-                                // FIXME: unhardcode this when we add support for other symbol kinds!
-                                let is_symbols =
-                                    path.extension().map(|e| e == "pdb").unwrap_or(false);
-                                if is_symbols {
-                                    // These are symbols we expected! Save the path.
-                                    *src_sym_path = path.to_owned();
-                                }
-                            }
-                        }
-                    }
-
-                    // Get the exe path
-                    let expected_exe = expected_exes
-                        .get_mut(&package_id)
-                        .and_then(|m| m.get_mut(exe_name));
-                    if let Some(expected) = expected_exe {
-                        for (src_bin_path, _) in expected {
-                            // This is an exe we expected! Save the path.
-                            *src_bin_path = new_exe.clone();
-                        }
-                    }
-                }
-            }
-            _ => {
-                // Nothing else interesting?
-            }
-        }
-    }
-
-    // Check that we got everything we expected, and normalize to ArtifactIdx => Artifact Path
-    for (package_id, exes) in expected_exes {
-        for (exe_name, to_copy) in &exes {
-            for (src_path, dest_path) in to_copy {
-                if src_path.as_str().is_empty() {
-                    return Err(miette!(
-                        "failed to find bin {} ({}) -- did the cargo build above have errors?",
-                        exe_name,
-                        package_id
-                    ));
-                }
-                copy_file(src_path, dest_path)?;
-            }
-        }
-    }
-    for (package_id, symbols) in expected_symbols {
-        for (exe, to_copy) in &symbols {
-            for (src_path, dest_path) in to_copy {
-                if src_path.as_str().is_empty() {
-                    return Err(miette!(
-                        "failed to find symbols for bin {} ({}) -- did the cargo build above have errors?",
-                        exe,
-                        package_id
-                    ));
-                }
-                copy_file(src_path, dest_path)?;
-            }
-        }
-    }
-
-    Ok(())
-}
-
-/// Build a cargo target
-fn rustup_toolchain(_dist_graph: &DistGraph, cmd: &RustupStep) -> Result<()> {
-    eprintln!("running rustup to ensure you have {} installed", cmd.target);
-    let status = Command::new(&cmd.rustup.cmd)
-        .arg("target")
-        .arg("add")
-        .arg(&cmd.target)
-        .status()
-        .into_diagnostic()
-        .wrap_err("Failed to install rustup toolchain")?;
-
-    if !status.success() {
-        return Err(miette!("Failed to install rustup toolchain"));
-    }
-    Ok(())
-}
-
 /// Initialize the dir for an artifact (and delete the old artifact file).
 fn init_artifact_dir(_dist: &DistGraph, artifact: &Artifact) -> Result<()> {
     // Delete any existing bundle
@@ -800,17 +258,6 @@ pub struct GenerateArgs {
     pub modes: Vec<GenerateMode>,
 }
 
-/// Arguments for `cargo dist linkage` ([`do_linkage][])
-#[derive(Debug)]
-pub struct LinkageArgs {
-    /// Print human-readable output
-    pub print_output: bool,
-    /// Print output as JSON
-    pub print_json: bool,
-    /// Read linkage data from JSON rather than performing a live check
-    pub from_json: Option<String>,
-}
-
 fn do_generate_preflight_checks(dist: &DistGraph) -> Result<()> {
     // Enforce cargo-dist-version, unless...
     //
@@ -839,7 +286,7 @@ fn do_generate_preflight_checks(dist: &DistGraph) -> Result<()> {
 
 /// Generate any scripts which are relevant (impl of `cargo dist generate`)
 pub fn do_generate(cfg: &Config, args: &GenerateArgs) -> Result<()> {
-    let dist = gather_work(cfg)?;
+    let (dist, _manifest) = gather_work(cfg)?;
 
     run_generate(&dist, args)?;
 
@@ -903,463 +350,6 @@ pub fn run_generate(dist: &DistGraph, args: &GenerateArgs) -> Result<()> {
     Ok(())
 }
 
-fn fetch_linkage(
-    targets: Vec<String>,
-    artifacts: Vec<Artifact>,
-    dist_dir: Utf8PathBuf,
-) -> DistResult<Vec<Linkage>> {
-    let mut reports = vec![];
-
-    for target in targets {
-        let artifacts: Vec<Artifact> = artifacts
-            .clone()
-            .into_iter()
-            .filter(|r| r.target_triples.contains(&target))
-            .collect();
-
-        if artifacts.is_empty() {
-            eprintln!("No matching artifact for target {target}");
-            continue;
-        }
-
-        for artifact in artifacts {
-            let path = Utf8PathBuf::from(&dist_dir).join(format!("{}-{target}", artifact.id));
-
-            for (_, binary) in artifact.required_binaries {
-                let bin_path = path.join(binary);
-                if !bin_path.exists() {
-                    eprintln!("Binary {bin_path} missing; skipping check");
-                } else {
-                    reports.push(determine_linkage(&bin_path, &target)?);
-                }
-            }
-        }
-    }
-
-    Ok(reports)
-}
-
-/// Determinage dynamic linkage of built artifacts (impl of `cargo dist linkage`)
-pub fn do_linkage(cfg: &Config, args: &LinkageArgs) -> Result<()> {
-    let dist = gather_work(cfg)?;
-
-    let reports: Vec<Linkage> = if let Some(target) = args.from_json.clone() {
-        let file = SourceFile::load_local(&target)?;
-        file.deserialize_json()?
-    } else {
-        fetch_linkage(cfg.targets.clone(), dist.artifacts, dist.dist_dir)?
-    };
-
-    if args.print_output {
-        for report in &reports {
-            eprintln!("{}", report.report());
-        }
-    }
-    if args.print_json {
-        let j = serde_json::to_string(&reports).unwrap();
-        println!("{}", j);
-    }
-
-    Ok(())
-}
-
-/// Information about dynamic libraries used by a binary
-#[derive(Debug, Deserialize, Serialize)]
-pub struct Linkage {
-    /// The filename of the binary
-    pub binary: String,
-    /// The target triple for which the binary was built
-    pub target: String,
-    /// Libraries included with the operating system
-    pub system: Vec<Library>,
-    /// Libraries provided by the Homebrew package manager
-    pub homebrew: Vec<Library>,
-    /// Public libraries not provided by the system and not managed by any package manager
-    pub public_unmanaged: Vec<Library>,
-    /// Libraries which don't fall into any other categories
-    pub other: Vec<Library>,
-    /// Frameworks, only used on macOS
-    pub frameworks: Vec<Library>,
-}
-
-impl Linkage {
-    /// Formatted human-readable output
-    pub fn report(&self) -> String {
-        let mut table = Table::new();
-        table
-            .load_preset(UTF8_FULL)
-            .set_header(vec!["Category", "Libraries"])
-            .add_row(vec![
-                "System",
-                self.system
-                    .clone()
-                    .into_iter()
-                    .map(|l| l.to_string_pretty())
-                    .collect::<Vec<String>>()
-                    .join("\n")
-                    .as_str(),
-            ])
-            .add_row(vec![
-                "Homebrew",
-                self.homebrew
-                    .clone()
-                    .into_iter()
-                    .map(|l| l.to_string_pretty())
-                    .collect::<Vec<String>>()
-                    .join("\n")
-                    .as_str(),
-            ])
-            .add_row(vec![
-                "Public (unmanaged)",
-                self.public_unmanaged
-                    .clone()
-                    .into_iter()
-                    .map(|l| l.path)
-                    .collect::<Vec<String>>()
-                    .join("\n")
-                    .as_str(),
-            ])
-            .add_row(vec![
-                "Frameworks",
-                self.frameworks
-                    .clone()
-                    .into_iter()
-                    .map(|l| l.path)
-                    .collect::<Vec<String>>()
-                    .join("\n")
-                    .as_str(),
-            ])
-            .add_row(vec![
-                "Other",
-                self.other
-                    .clone()
-                    .into_iter()
-                    .map(|l| l.to_string_pretty())
-                    .collect::<Vec<String>>()
-                    .join("\n")
-                    .as_str(),
-            ]);
-
-        let s = format!(
-            r#"{} ({}):
-
-{table}"#,
-            self.binary, self.target,
-        );
-
-        s.to_owned()
-    }
-
-    fn to_schema(&self) -> cargo_dist_schema::Linkage {
-        cargo_dist_schema::Linkage {
-            binary: self.binary.clone(),
-            target: self.target.clone(),
-            system: self.system.iter().map(|s| s.to_schema()).collect(),
-            homebrew: self.homebrew.iter().map(|s| s.to_schema()).collect(),
-            public_unmanaged: self
-                .public_unmanaged
-                .iter()
-                .map(|s| s.to_schema())
-                .collect(),
-            other: self.other.iter().map(|s| s.to_schema()).collect(),
-            frameworks: self.frameworks.iter().map(|s| s.to_schema()).collect(),
-        }
-    }
-
-    /// Constructs a Linkage from a cargo_dist_schema::Linkage
-    pub fn from_schema(other: &cargo_dist_schema::Linkage) -> Self {
-        Self {
-            binary: other.binary.clone(),
-            target: other.target.clone(),
-            system: other.system.iter().map(Library::from_schema).collect(),
-            homebrew: other.homebrew.iter().map(Library::from_schema).collect(),
-            public_unmanaged: other
-                .public_unmanaged
-                .iter()
-                .map(Library::from_schema)
-                .collect(),
-            other: other.other.iter().map(Library::from_schema).collect(),
-            frameworks: other.frameworks.iter().map(Library::from_schema).collect(),
-        }
-    }
-}
-
-/// Represents a dynamic library located somewhere on the system
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub struct Library {
-    /// The path to the library; on platforms without that information, it will be a basename instead
-    pub path: String,
-    /// The package from which a library comes, if relevant
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub source: Option<String>,
-}
-
-impl Library {
-    fn new(library: String) -> Self {
-        Self {
-            path: library,
-            source: None,
-        }
-    }
-
-    fn to_schema(&self) -> cargo_dist_schema::Library {
-        cargo_dist_schema::Library {
-            path: self.path.clone(),
-            source: self.source.clone(),
-        }
-    }
-
-    fn from_schema(other: &cargo_dist_schema::Library) -> Self {
-        Self {
-            path: other.path.clone(),
-            source: other.source.clone(),
-        }
-    }
-
-    fn from_homebrew(library: String) -> Self {
-        // Doesn't currently support Homebrew installations in
-        // non-default locations
-        let brew_prefix = if library.starts_with("/opt/homebrew/opt/") {
-            Some("/opt/homebrew/opt/")
-        } else if library.starts_with("/usr/local/opt/") {
-            Some("/usr/local/opt/")
-        } else {
-            None
-        };
-
-        if let Some(prefix) = brew_prefix {
-            let cloned = library.clone();
-            let stripped = cloned.strip_prefix(prefix).unwrap();
-            let mut package = stripped.split('/').nth(0).unwrap().to_owned();
-
-            // The path alone isn't enough to determine the tap the formula
-            // came from. If the install receipt exists, we can use it to
-            // get the name of the source tap.
-            let receipt = Utf8PathBuf::from(&prefix)
-                .join(&package)
-                .join("INSTALL_RECEIPT.json");
-
-            // If the receipt doesn't exist or can't be loaded, that's not an
-            // error; we can fall back to the package basename we parsed out
-            // of the path.
-            if receipt.exists() {
-                let _ = SourceFile::load_local(&receipt)
-                    .and_then(|file| file.deserialize_json())
-                    .map(|parsed: serde_json::Value| {
-                        if let Some(tap) = parsed["source"]["tap"].as_str() {
-                            if tap != "homebrew/core" {
-                                package = format!("{tap}/{package}");
-                            }
-                        }
-                    });
-            }
-
-            Self {
-                path: library,
-                source: Some(package.to_owned()),
-            }
-        } else {
-            Self {
-                path: library,
-                source: None,
-            }
-        }
-    }
-
-    fn maybe_apt(library: String) -> DistResult<Self> {
-        // We can't get this information on other OSs
-        if std::env::consts::OS != "linux" {
-            return Ok(Self {
-                path: library,
-                source: None,
-            });
-        }
-
-        let process = Command::new("dpkg")
-            .arg("--search")
-            .arg(&library)
-            .output()
-            .into_diagnostic();
-        match process {
-            Ok(output) => {
-                let output = String::from_utf8(output.stdout)?;
-
-                let package = output.split(':').nth(0).unwrap();
-                let source = if package.is_empty() {
-                    None
-                } else {
-                    Some(package.to_owned())
-                };
-
-                Ok(Self {
-                    path: library,
-                    source,
-                })
-            }
-            // Couldn't find a package for this file
-            Err(_) => Ok(Self {
-                path: library,
-                source: None,
-            }),
-        }
-    }
-
-    fn to_string_pretty(&self) -> String {
-        if let Some(package) = &self.source {
-            format!("{} ({package})", self.path).to_owned()
-        } else {
-            self.path.clone()
-        }
-    }
-}
-
-fn do_otool(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
-    let mut libraries = vec![];
-
-    let mut f = File::open(path)?;
-    let mut buf = vec![];
-    let size = f.read_to_end(&mut buf).unwrap();
-    let mut cur = Cursor::new(&buf[..size]);
-    if let OFile::MachFile {
-        header: _,
-        commands,
-    } = OFile::parse(&mut cur).unwrap()
-    {
-        let commands = commands
-            .iter()
-            .map(|load| load.command())
-            .cloned()
-            .collect::<Vec<LoadCommand>>();
-
-        for command in commands {
-            match command {
-                LoadCommand::IdDyLib(ref dylib)
-                | LoadCommand::LoadDyLib(ref dylib)
-                | LoadCommand::LoadWeakDyLib(ref dylib)
-                | LoadCommand::ReexportDyLib(ref dylib)
-                | LoadCommand::LoadUpwardDylib(ref dylib)
-                | LoadCommand::LazyLoadDylib(ref dylib) => {
-                    libraries.push(dylib.name.to_string());
-                }
-                _ => {}
-            }
-        }
-    }
-
-    Ok(libraries)
-}
-
-fn do_ldd(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
-    let mut libraries = vec![];
-
-    let output = Command::new("ldd")
-        .arg(path)
-        .output()
-        .expect("Unable to run ldd");
-
-    let result = String::from_utf8_lossy(&output.stdout).to_string();
-    let lines = result.trim_end().split('\n');
-
-    for line in lines {
-        let line = line.trim();
-
-        // There's no dynamic linkage at all; we can safely break,
-        // there will be nothing useful to us here.
-        if line.starts_with("not a dynamic executable") {
-            break;
-        }
-
-        // Not a library that actually concerns us
-        if line.starts_with("linux-vdso") {
-            continue;
-        }
-
-        // Format: libname.so.1 => /path/to/libname.so.1 (address)
-        if let Some(path) = line.split(" => ").nth(1) {
-            // This may be a symlink rather than the actual underlying library;
-            // we resolve the symlink here so that we return the real paths,
-            // making it easier to map them to their packages later.
-            let lib = (path.split(' ').next().unwrap()).to_owned();
-            let realpath = fs::canonicalize(&lib)?;
-            libraries.push(realpath.to_string_lossy().to_string());
-        } else {
-            continue;
-        }
-    }
-
-    Ok(libraries)
-}
-
-fn do_pe(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
-    let buf = std::fs::read(path)?;
-    match Object::parse(&buf)? {
-        Object::PE(pe) => Ok(pe.libraries.into_iter().map(|s| s.to_owned()).collect()),
-        _ => Err(DistError::LinkageCheckUnsupportedBinary {}),
-    }
-}
-
-fn determine_linkage(path: &Utf8PathBuf, target: &str) -> DistResult<Linkage> {
-    let libraries = match target {
-        // Can be run on any OS
-        "i686-apple-darwin" | "x86_64-apple-darwin" | "aarch64-apple-darwin" => do_otool(path)?,
-        "i686-unknown-linux-gnu"
-        | "x86_64-unknown-linux-gnu"
-        | "aarch64-unknown-linux-gnu"
-        | "i686-unknown-linux-musl"
-        | "x86_64-unknown-linux-musl"
-        | "aarch64-unknown-linux-musl" => {
-            // Currently can only be run on Linux
-            if std::env::consts::OS != "linux" {
-                return Err(DistError::LinkageCheckInvalidOS {
-                    host: std::env::consts::OS.to_owned(),
-                    target: target.to_owned(),
-                });
-            }
-            do_ldd(path)?
-        }
-        // Can be run on any OS
-        "i686-pc-windows-msvc" | "x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => {
-            do_pe(path)?
-        }
-        _ => return Err(DistError::LinkageCheckUnsupportedBinary {}),
-    };
-
-    let mut linkage = Linkage {
-        binary: path.file_name().unwrap().to_owned(),
-        target: target.to_owned(),
-        system: vec![],
-        homebrew: vec![],
-        public_unmanaged: vec![],
-        frameworks: vec![],
-        other: vec![],
-    };
-    for library in libraries {
-        if library.starts_with("/opt/homebrew") {
-            linkage
-                .homebrew
-                .push(Library::from_homebrew(library.clone()));
-        } else if library.starts_with("/usr/lib") || library.starts_with("/lib") {
-            linkage.system.push(Library::maybe_apt(library.clone())?);
-        } else if library.starts_with("/System/Library/Frameworks")
-            || library.starts_with("/Library/Frameworks")
-        {
-            linkage.frameworks.push(Library::new(library.clone()));
-        } else if library.starts_with("/usr/local") {
-            if std::fs::canonicalize(&library)?.starts_with("/usr/local/Cellar") {
-                linkage
-                    .homebrew
-                    .push(Library::from_homebrew(library.clone()));
-            } else {
-                linkage.public_unmanaged.push(Library::new(library.clone()));
-            }
-        } else {
-            linkage.other.push(Library::maybe_apt(library.clone())?);
-        }
-    }
-
-    Ok(linkage)
-}
-
 /// Run any necessary integrity checks for "primary" commands like build/plan
 ///
 /// (This is currently equivalent to `cargo dist generate --check`)
@@ -1376,7 +366,7 @@ pub fn check_integrity(cfg: &Config) -> Result<()> {
         installers: vec![],
         announcement_tag: None,
     };
-    let dist = tasks::gather_work(&check_config)?;
+    let (dist, _manifest) = tasks::gather_work(&check_config)?;
 
     run_generate(
         &dist,
@@ -1391,7 +381,7 @@ pub fn check_integrity(cfg: &Config) -> Result<()> {
 fn generate_installer(
     dist: &DistGraph,
     style: &InstallerImpl,
-    manifests: &[DistManifest],
+    manifest: &DistManifest,
 ) -> Result<()> {
     match style {
         InstallerImpl::Shell(info) => {
@@ -1402,7 +392,7 @@ fn generate_installer(
         }
         InstallerImpl::Npm(info) => installer::npm::write_npm_project(&dist.templates, info)?,
         InstallerImpl::Homebrew(info) => {
-            installer::homebrew::write_homebrew_formula(&dist.templates, dist, info, manifests)?
+            installer::homebrew::write_homebrew_formula(&dist.templates, dist, info, manifest)?
         }
         InstallerImpl::Msi(info) => info.build()?,
     }
diff --git a/cargo-dist/src/linkage.rs b/cargo-dist/src/linkage.rs
new file mode 100644
index 00000000..9f11072b
--- /dev/null
+++ b/cargo-dist/src/linkage.rs
@@ -0,0 +1,504 @@
+//! The Linkage Checker, which lets us detect what a binary dynamically links to (and why)
+
+use std::{
+    fs::{self, File},
+    io::{Cursor, Read},
+    process::Command,
+};
+
+use axoasset::SourceFile;
+use camino::Utf8PathBuf;
+use cargo_dist_schema::DistManifest;
+use comfy_table::{presets::UTF8_FULL, Table};
+use goblin::Object;
+use mach_object::{LoadCommand, OFile};
+use miette::IntoDiagnostic;
+use serde::{Deserialize, Serialize};
+
+use crate::{config::Config, errors::*, gather_work, Artifact, DistGraph};
+
+/// Arguments for `cargo dist linkage` ([`do_linkage][])
+#[derive(Debug)]
+pub struct LinkageArgs {
+    /// Print human-readable output
+    pub print_output: bool,
+    /// Print output as JSON
+    pub print_json: bool,
+    /// Read linkage data from JSON rather than performing a live check
+    pub from_json: Option<String>,
+}
+
+/// Determinage dynamic linkage of built artifacts (impl of `cargo dist linkage`)
+pub fn do_linkage(cfg: &Config, args: &LinkageArgs) -> Result<()> {
+    let (dist, _manifest) = gather_work(cfg)?;
+
+    let reports: Vec<Linkage> = if let Some(target) = args.from_json.clone() {
+        let file = SourceFile::load_local(&target)?;
+        file.deserialize_json()?
+    } else {
+        fetch_linkage(cfg.targets.clone(), dist.artifacts, dist.dist_dir)?
+    };
+
+    if args.print_output {
+        for report in &reports {
+            eprintln!("{}", report.report());
+        }
+    }
+    if args.print_json {
+        let j = serde_json::to_string(&reports).unwrap();
+        println!("{}", j);
+    }
+
+    Ok(())
+}
+
+/// Compute the linkage of local builds and add them to the DistManifest
+pub fn add_linkage_to_manifest(
+    cfg: &Config,
+    dist: &DistGraph,
+    manifest: &mut DistManifest,
+) -> Result<()> {
+    let linkage = fetch_linkage(
+        cfg.targets.clone(),
+        dist.artifacts.clone(),
+        dist.dist_dir.clone(),
+    )?;
+
+    manifest
+        .linkage
+        .extend(linkage.iter().map(|l| l.to_schema()));
+    Ok(())
+}
+
+fn fetch_linkage(
+    targets: Vec<String>,
+    artifacts: Vec<Artifact>,
+    dist_dir: Utf8PathBuf,
+) -> DistResult<Vec<Linkage>> {
+    let mut reports = vec![];
+
+    for target in targets {
+        let artifacts: Vec<Artifact> = artifacts
+            .clone()
+            .into_iter()
+            .filter(|r| r.target_triples.contains(&target))
+            .collect();
+
+        if artifacts.is_empty() {
+            eprintln!("No matching artifact for target {target}");
+            continue;
+        }
+
+        for artifact in artifacts {
+            let path = Utf8PathBuf::from(&dist_dir).join(format!("{}-{target}", artifact.id));
+
+            for (_, binary) in artifact.required_binaries {
+                let bin_path = path.join(binary);
+                if !bin_path.exists() {
+                    eprintln!("Binary {bin_path} missing; skipping check");
+                } else {
+                    reports.push(determine_linkage(&bin_path, &target)?);
+                }
+            }
+        }
+    }
+
+    Ok(reports)
+}
+
+/// Information about dynamic libraries used by a binary
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Linkage {
+    /// The filename of the binary
+    pub binary: String,
+    /// The target triple for which the binary was built
+    pub target: String,
+    /// Libraries included with the operating system
+    pub system: Vec<Library>,
+    /// Libraries provided by the Homebrew package manager
+    pub homebrew: Vec<Library>,
+    /// Public libraries not provided by the system and not managed by any package manager
+    pub public_unmanaged: Vec<Library>,
+    /// Libraries which don't fall into any other categories
+    pub other: Vec<Library>,
+    /// Frameworks, only used on macOS
+    pub frameworks: Vec<Library>,
+}
+
+impl Linkage {
+    /// Formatted human-readable output
+    pub fn report(&self) -> String {
+        let mut table = Table::new();
+        table
+            .load_preset(UTF8_FULL)
+            .set_header(vec!["Category", "Libraries"])
+            .add_row(vec![
+                "System",
+                self.system
+                    .clone()
+                    .into_iter()
+                    .map(|l| l.to_string_pretty())
+                    .collect::<Vec<String>>()
+                    .join("\n")
+                    .as_str(),
+            ])
+            .add_row(vec![
+                "Homebrew",
+                self.homebrew
+                    .clone()
+                    .into_iter()
+                    .map(|l| l.to_string_pretty())
+                    .collect::<Vec<String>>()
+                    .join("\n")
+                    .as_str(),
+            ])
+            .add_row(vec![
+                "Public (unmanaged)",
+                self.public_unmanaged
+                    .clone()
+                    .into_iter()
+                    .map(|l| l.path)
+                    .collect::<Vec<String>>()
+                    .join("\n")
+                    .as_str(),
+            ])
+            .add_row(vec![
+                "Frameworks",
+                self.frameworks
+                    .clone()
+                    .into_iter()
+                    .map(|l| l.path)
+                    .collect::<Vec<String>>()
+                    .join("\n")
+                    .as_str(),
+            ])
+            .add_row(vec![
+                "Other",
+                self.other
+                    .clone()
+                    .into_iter()
+                    .map(|l| l.to_string_pretty())
+                    .collect::<Vec<String>>()
+                    .join("\n")
+                    .as_str(),
+            ]);
+
+        let s = format!(
+            r#"{} ({}):
+
+{table}"#,
+            self.binary, self.target,
+        );
+
+        s.to_owned()
+    }
+
+    fn to_schema(&self) -> cargo_dist_schema::Linkage {
+        cargo_dist_schema::Linkage {
+            binary: self.binary.clone(),
+            target: self.target.clone(),
+            system: self.system.iter().map(|s| s.to_schema()).collect(),
+            homebrew: self.homebrew.iter().map(|s| s.to_schema()).collect(),
+            public_unmanaged: self
+                .public_unmanaged
+                .iter()
+                .map(|s| s.to_schema())
+                .collect(),
+            other: self.other.iter().map(|s| s.to_schema()).collect(),
+            frameworks: self.frameworks.iter().map(|s| s.to_schema()).collect(),
+        }
+    }
+
+    /// Constructs a Linkage from a cargo_dist_schema::Linkage
+    pub fn from_schema(other: &cargo_dist_schema::Linkage) -> Self {
+        Self {
+            binary: other.binary.clone(),
+            target: other.target.clone(),
+            system: other.system.iter().map(Library::from_schema).collect(),
+            homebrew: other.homebrew.iter().map(Library::from_schema).collect(),
+            public_unmanaged: other
+                .public_unmanaged
+                .iter()
+                .map(Library::from_schema)
+                .collect(),
+            other: other.other.iter().map(Library::from_schema).collect(),
+            frameworks: other.frameworks.iter().map(Library::from_schema).collect(),
+        }
+    }
+}
+
+/// Represents a dynamic library located somewhere on the system
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Library {
+    /// The path to the library; on platforms without that information, it will be a basename instead
+    pub path: String,
+    /// The package from which a library comes, if relevant
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub source: Option<String>,
+}
+
+impl Library {
+    fn new(library: String) -> Self {
+        Self {
+            path: library,
+            source: None,
+        }
+    }
+
+    fn to_schema(&self) -> cargo_dist_schema::Library {
+        cargo_dist_schema::Library {
+            path: self.path.clone(),
+            source: self.source.clone(),
+        }
+    }
+
+    fn from_schema(other: &cargo_dist_schema::Library) -> Self {
+        Self {
+            path: other.path.clone(),
+            source: other.source.clone(),
+        }
+    }
+
+    fn from_homebrew(library: String) -> Self {
+        // Doesn't currently support Homebrew installations in
+        // non-default locations
+        let brew_prefix = if library.starts_with("/opt/homebrew/opt/") {
+            Some("/opt/homebrew/opt/")
+        } else if library.starts_with("/usr/local/opt/") {
+            Some("/usr/local/opt/")
+        } else {
+            None
+        };
+
+        if let Some(prefix) = brew_prefix {
+            let cloned = library.clone();
+            let stripped = cloned.strip_prefix(prefix).unwrap();
+            let mut package = stripped.split('/').nth(0).unwrap().to_owned();
+
+            // The path alone isn't enough to determine the tap the formula
+            // came from. If the install receipt exists, we can use it to
+            // get the name of the source tap.
+            let receipt = Utf8PathBuf::from(&prefix)
+                .join(&package)
+                .join("INSTALL_RECEIPT.json");
+
+            // If the receipt doesn't exist or can't be loaded, that's not an
+            // error; we can fall back to the package basename we parsed out
+            // of the path.
+            if receipt.exists() {
+                let _ = SourceFile::load_local(&receipt)
+                    .and_then(|file| file.deserialize_json())
+                    .map(|parsed: serde_json::Value| {
+                        if let Some(tap) = parsed["source"]["tap"].as_str() {
+                            if tap != "homebrew/core" {
+                                package = format!("{tap}/{package}");
+                            }
+                        }
+                    });
+            }
+
+            Self {
+                path: library,
+                source: Some(package.to_owned()),
+            }
+        } else {
+            Self {
+                path: library,
+                source: None,
+            }
+        }
+    }
+
+    fn maybe_apt(library: String) -> DistResult<Self> {
+        // We can't get this information on other OSs
+        if std::env::consts::OS != "linux" {
+            return Ok(Self {
+                path: library,
+                source: None,
+            });
+        }
+
+        let process = Command::new("dpkg")
+            .arg("--search")
+            .arg(&library)
+            .output()
+            .into_diagnostic();
+        match process {
+            Ok(output) => {
+                let output = String::from_utf8(output.stdout)?;
+
+                let package = output.split(':').nth(0).unwrap();
+                let source = if package.is_empty() {
+                    None
+                } else {
+                    Some(package.to_owned())
+                };
+
+                Ok(Self {
+                    path: library,
+                    source,
+                })
+            }
+            // Couldn't find a package for this file
+            Err(_) => Ok(Self {
+                path: library,
+                source: None,
+            }),
+        }
+    }
+
+    fn to_string_pretty(&self) -> String {
+        if let Some(package) = &self.source {
+            format!("{} ({package})", self.path).to_owned()
+        } else {
+            self.path.clone()
+        }
+    }
+}
+
+fn do_otool(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
+    let mut libraries = vec![];
+
+    let mut f = File::open(path)?;
+    let mut buf = vec![];
+    let size = f.read_to_end(&mut buf).unwrap();
+    let mut cur = Cursor::new(&buf[..size]);
+    if let OFile::MachFile {
+        header: _,
+        commands,
+    } = OFile::parse(&mut cur).unwrap()
+    {
+        let commands = commands
+            .iter()
+            .map(|load| load.command())
+            .cloned()
+            .collect::<Vec<LoadCommand>>();
+
+        for command in commands {
+            match command {
+                LoadCommand::IdDyLib(ref dylib)
+                | LoadCommand::LoadDyLib(ref dylib)
+                | LoadCommand::LoadWeakDyLib(ref dylib)
+                | LoadCommand::ReexportDyLib(ref dylib)
+                | LoadCommand::LoadUpwardDylib(ref dylib)
+                | LoadCommand::LazyLoadDylib(ref dylib) => {
+                    libraries.push(dylib.name.to_string());
+                }
+                _ => {}
+            }
+        }
+    }
+
+    Ok(libraries)
+}
+
+fn do_ldd(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
+    let mut libraries = vec![];
+
+    let output = Command::new("ldd")
+        .arg(path)
+        .output()
+        .expect("Unable to run ldd");
+
+    let result = String::from_utf8_lossy(&output.stdout).to_string();
+    let lines = result.trim_end().split('\n');
+
+    for line in lines {
+        let line = line.trim();
+
+        // There's no dynamic linkage at all; we can safely break,
+        // there will be nothing useful to us here.
+        if line.starts_with("not a dynamic executable") {
+            break;
+        }
+
+        // Not a library that actually concerns us
+        if line.starts_with("linux-vdso") {
+            continue;
+        }
+
+        // Format: libname.so.1 => /path/to/libname.so.1 (address)
+        if let Some(path) = line.split(" => ").nth(1) {
+            // This may be a symlink rather than the actual underlying library;
+            // we resolve the symlink here so that we return the real paths,
+            // making it easier to map them to their packages later.
+            let lib = (path.split(' ').next().unwrap()).to_owned();
+            let realpath = fs::canonicalize(&lib)?;
+            libraries.push(realpath.to_string_lossy().to_string());
+        } else {
+            continue;
+        }
+    }
+
+    Ok(libraries)
+}
+
+fn do_pe(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
+    let buf = std::fs::read(path)?;
+    match Object::parse(&buf)? {
+        Object::PE(pe) => Ok(pe.libraries.into_iter().map(|s| s.to_owned()).collect()),
+        _ => Err(DistError::LinkageCheckUnsupportedBinary {}),
+    }
+}
+
+fn determine_linkage(path: &Utf8PathBuf, target: &str) -> DistResult<Linkage> {
+    let libraries = match target {
+        // Can be run on any OS
+        "i686-apple-darwin" | "x86_64-apple-darwin" | "aarch64-apple-darwin" => do_otool(path)?,
+        "i686-unknown-linux-gnu"
+        | "x86_64-unknown-linux-gnu"
+        | "aarch64-unknown-linux-gnu"
+        | "i686-unknown-linux-musl"
+        | "x86_64-unknown-linux-musl"
+        | "aarch64-unknown-linux-musl" => {
+            // Currently can only be run on Linux
+            if std::env::consts::OS != "linux" {
+                return Err(DistError::LinkageCheckInvalidOS {
+                    host: std::env::consts::OS.to_owned(),
+                    target: target.to_owned(),
+                });
+            }
+            do_ldd(path)?
+        }
+        // Can be run on any OS
+        "i686-pc-windows-msvc" | "x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => {
+            do_pe(path)?
+        }
+        _ => return Err(DistError::LinkageCheckUnsupportedBinary {}),
+    };
+
+    let mut linkage = Linkage {
+        binary: path.file_name().unwrap().to_owned(),
+        target: target.to_owned(),
+        system: vec![],
+        homebrew: vec![],
+        public_unmanaged: vec![],
+        frameworks: vec![],
+        other: vec![],
+    };
+    for library in libraries {
+        if library.starts_with("/opt/homebrew") {
+            linkage
+                .homebrew
+                .push(Library::from_homebrew(library.clone()));
+        } else if library.starts_with("/usr/lib") || library.starts_with("/lib") {
+            linkage.system.push(Library::maybe_apt(library.clone())?);
+        } else if library.starts_with("/System/Library/Frameworks")
+            || library.starts_with("/Library/Frameworks")
+        {
+            linkage.frameworks.push(Library::new(library.clone()));
+        } else if library.starts_with("/usr/local") {
+            if std::fs::canonicalize(&library)?.starts_with("/usr/local/Cellar") {
+                linkage
+                    .homebrew
+                    .push(Library::from_homebrew(library.clone()));
+            } else {
+                linkage.public_unmanaged.push(Library::new(library.clone()));
+            }
+        } else {
+            linkage.other.push(Library::maybe_apt(library.clone())?);
+        }
+    }
+
+    Ok(linkage)
+}
diff --git a/cargo-dist/src/main.rs b/cargo-dist/src/main.rs
index 67cb9381..b33b04de 100644
--- a/cargo-dist/src/main.rs
+++ b/cargo-dist/src/main.rs
@@ -6,7 +6,7 @@ use std::io::Write;
 
 use camino::Utf8PathBuf;
 // Import everything from the lib version of ourselves
-use cargo_dist::*;
+use cargo_dist::{linkage::Linkage, *};
 use cargo_dist_schema::{AssetKind, DistManifest};
 use clap::Parser;
 use cli::{
@@ -265,7 +265,7 @@ fn cmd_linkage(cli: &Cli, args: &LinkageArgs) -> Result<(), miette::Report> {
         installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(),
         announcement_tag: cli.tag.clone(),
     };
-    let mut options = cargo_dist::LinkageArgs {
+    let mut options = cargo_dist::linkage::LinkageArgs {
         print_output: args.print_output,
         print_json: args.print_json,
         from_json: args.from_json.clone(),
@@ -273,7 +273,7 @@ fn cmd_linkage(cli: &Cli, args: &LinkageArgs) -> Result<(), miette::Report> {
     if !args.print_output && !args.print_json {
         options.print_output = true;
     }
-    do_linkage(&config, &options)
+    cargo_dist::linkage::do_linkage(&config, &options)
 }
 
 fn cmd_generate_ci(cli: &Cli, args: &GenerateCiArgs) -> Result<(), miette::Report> {
diff --git a/cargo-dist/src/manifest.rs b/cargo-dist/src/manifest.rs
new file mode 100644
index 00000000..3b43c903
--- /dev/null
+++ b/cargo-dist/src/manifest.rs
@@ -0,0 +1,280 @@
+//! Utilities for managing DistManifests
+
+use std::collections::BTreeMap;
+
+use camino::{Utf8Path, Utf8PathBuf};
+use cargo_dist_schema::{Asset, AssetKind, DistManifest, ExecutableAsset};
+
+use crate::{
+    backend::{
+        installer::{homebrew::HomebrewInstallerInfo, npm::NpmInstallerInfo, InstallerImpl},
+        templates::{TemplateEntry, TEMPLATE_INSTALLER_NPM},
+    },
+    config::Config,
+    errors::DistResult,
+    ArtifactIdx, ArtifactKind, DistGraph, StaticAssetKind,
+};
+
+/// Load DistManifests into the given dir and merge them into the current one
+pub fn load_and_merge_manifests(
+    manifest_dir: &Utf8Path,
+    output: &mut DistManifest,
+) -> DistResult<()> {
+    // Hey! Update the loop below too if you're adding a field!
+
+    let manifests = load_manifests(manifest_dir)?;
+    for manifest in manifests {
+        let DistManifest {
+            // There's one value and N machines (redesign required for per-machine values)
+            // although dist_version *really* should be stable across all machines
+            dist_version: _,
+            // one value N machines
+            system_info: _,
+            // These should absolutely be merged, but we currently rely on these values
+            // being a bit constrained to the work the local machine is doing to compute
+            // what to upload from that machine. In the future we should do some refactors
+            // to make merging these possible!
+            releases: _,
+            artifacts: _,
+
+            publish_prereleases,
+            announcement_tag,
+            announcement_is_prerelease,
+            announcement_title,
+            announcement_changelog,
+            announcement_github_body,
+            ci,
+            linkage,
+            hosting,
+        }: DistManifest = manifest;
+
+        if let Some(val) = announcement_tag {
+            output.announcement_tag = Some(val);
+            // Didn't wrap these in an option, so use announcement_tag as a proxy
+            output.announcement_is_prerelease = announcement_is_prerelease;
+            output.publish_prereleases = publish_prereleases;
+        }
+        if let Some(val) = announcement_title {
+            output.announcement_title = Some(val);
+        }
+        if let Some(val) = announcement_changelog {
+            output.announcement_changelog = Some(val);
+        }
+        if let Some(val) = announcement_github_body {
+            output.announcement_github_body = Some(val);
+        }
+        if let Some(val) = hosting {
+            // Don't bother doing an inner merge here, all or nothing
+            output.hosting = Some(val);
+        };
+        if let Some(val) = ci {
+            // Don't bother doing an inner merge here, all or nothing
+            output.ci = Some(val);
+        };
+
+        // Just merge all the linkage
+        output.linkage.extend(linkage);
+    }
+
+    Ok(())
+}
+
+/// Load manifests from the current dir
+fn load_manifests(manifest_dir: &Utf8Path) -> DistResult<Vec<crate::DistManifest>> {
+    // This happens on clean builds with no manifests to slurp up, and the dist-dir
+    // not yet created. In that case there's clearly nothing to import!
+    if !manifest_dir.exists() {
+        return Ok(vec![]);
+    }
+
+    // Collect all dist-manifests and fetch the appropriate Mac ones
+    let mut manifests = vec![];
+    for file in manifest_dir.read_dir()? {
+        let path = file?.path();
+        if let Some(filename) = path.file_name() {
+            if !filename.to_string_lossy().ends_with("dist-manifest.json") {
+                continue;
+            }
+        }
+
+        let json_path = Utf8PathBuf::try_from(path)?;
+        let data = axoasset::SourceFile::load_local(json_path)?;
+        let manifest: crate::DistManifest = data.deserialize_json()?;
+
+        manifests.push(manifest);
+    }
+    Ok(manifests)
+}
+
+/// Add release/artifact info to the current dist-manifest
+pub(crate) fn add_releases_to_manifest(
+    cfg: &Config,
+    dist: &DistGraph,
+    manifest: &mut DistManifest,
+) -> DistResult<()> {
+    let mut releases = vec![];
+    let mut all_artifacts = BTreeMap::<String, cargo_dist_schema::Artifact>::new();
+    for release in &dist.releases {
+        // Gather up all the local and global artifacts
+        let mut artifacts = vec![];
+        for &artifact_idx in &release.global_artifacts {
+            let id = &dist.artifact(artifact_idx).id;
+            all_artifacts.insert(id.clone(), manifest_artifact(cfg, dist, artifact_idx));
+            artifacts.push(id.clone());
+        }
+        for &variant_idx in &release.variants {
+            let variant = dist.variant(variant_idx);
+            for &artifact_idx in &variant.local_artifacts {
+                let id = &dist.artifact(artifact_idx).id;
+                all_artifacts.insert(id.clone(), manifest_artifact(cfg, dist, artifact_idx));
+                artifacts.push(id.clone());
+            }
+        }
+
+        // And report the release
+        releases.push(cargo_dist_schema::Release {
+            app_name: release.app_name.clone(),
+            app_version: release.version.to_string(),
+            artifacts,
+        })
+    }
+
+    manifest.releases = releases;
+    manifest.artifacts = all_artifacts;
+
+    Ok(())
+}
+
+fn manifest_artifact(
+    cfg: &Config,
+    dist: &DistGraph,
+    artifact_idx: ArtifactIdx,
+) -> cargo_dist_schema::Artifact {
+    let artifact = dist.artifact(artifact_idx);
+    let mut assets = vec![];
+
+    let built_assets = artifact
+        .required_binaries
+        .iter()
+        .map(|(&binary_idx, exe_path)| {
+            let binary = &dist.binary(binary_idx);
+            let symbols_artifact = binary.symbols_artifact.map(|a| dist.artifact(a).id.clone());
+            Asset {
+                name: Some(binary.name.clone()),
+                // Always copied to the root... for now
+                path: Some(exe_path.file_name().unwrap().to_owned()),
+                kind: AssetKind::Executable(ExecutableAsset { symbols_artifact }),
+            }
+        });
+
+    let mut static_assets = artifact
+        .archive
+        .as_ref()
+        .map(|archive| {
+            archive
+                .static_assets
+                .iter()
+                .map(|(kind, asset)| {
+                    let kind = match kind {
+                        StaticAssetKind::Changelog => AssetKind::Changelog,
+                        StaticAssetKind::License => AssetKind::License,
+                        StaticAssetKind::Readme => AssetKind::Readme,
+                        StaticAssetKind::Other => AssetKind::Unknown,
+                    };
+                    Asset {
+                        name: Some(asset.file_name().unwrap().to_owned()),
+                        path: Some(asset.file_name().unwrap().to_owned()),
+                        kind,
+                    }
+                })
+                .collect::<Vec<_>>()
+        })
+        .unwrap_or_default();
+
+    // Record the files that we always add to an npm package
+    //
+    // These can't be pre-included in the normal static assets list above because
+    // they're generated from templates, and not copied from the user's project.
+    if let ArtifactKind::Installer(InstallerImpl::Npm(..)) = &artifact.kind {
+        let root_dir = dist
+            .templates
+            .get_template_dir(TEMPLATE_INSTALLER_NPM)
+            .expect("npm template missing!?");
+        let mut queue = vec![root_dir];
+        while let Some(dir) = queue.pop() {
+            for entry in dir.entries.values() {
+                match entry {
+                    TemplateEntry::Dir(dir) => {
+                        queue.push(dir);
+                    }
+                    TemplateEntry::File(file) => {
+                        static_assets.push(Asset {
+                            name: Some(file.name.clone()),
+                            path: Some(file.path_from_ancestor(root_dir).to_string()),
+                            kind: AssetKind::Unknown,
+                        });
+                    }
+                }
+            }
+        }
+    }
+
+    assets.extend(built_assets);
+    assets.extend(static_assets);
+    // Sort the assets by name to make things extra stable
+    assets.sort_by(|k1, k2| k1.name.cmp(&k2.name));
+
+    let install_hint;
+    let description;
+    let kind;
+
+    match &artifact.kind {
+        ArtifactKind::ExecutableZip(_) => {
+            install_hint = None;
+            description = None;
+            kind = cargo_dist_schema::ArtifactKind::ExecutableZip;
+        }
+        ArtifactKind::Symbols(_) => {
+            install_hint = None;
+            description = None;
+            kind = cargo_dist_schema::ArtifactKind::Symbols;
+        }
+        ArtifactKind::Installer(
+            InstallerImpl::Powershell(info)
+            | InstallerImpl::Shell(info)
+            | InstallerImpl::Homebrew(HomebrewInstallerInfo { inner: info, .. })
+            | InstallerImpl::Npm(NpmInstallerInfo { inner: info, .. }),
+        ) => {
+            install_hint = Some(info.hint.clone());
+            description = Some(info.desc.clone());
+            kind = cargo_dist_schema::ArtifactKind::Installer;
+        }
+        ArtifactKind::Installer(InstallerImpl::Msi(..)) => {
+            install_hint = None;
+            description = Some("install via msi".to_owned());
+            kind = cargo_dist_schema::ArtifactKind::Installer;
+        }
+        ArtifactKind::Checksum(_) => {
+            install_hint = None;
+            description = None;
+            kind = cargo_dist_schema::ArtifactKind::Checksum;
+        }
+    };
+
+    let checksum = artifact.checksum.map(|idx| dist.artifact(idx).id.clone());
+
+    cargo_dist_schema::Artifact {
+        name: Some(artifact.id.clone()),
+        path: if cfg.no_local_paths {
+            None
+        } else {
+            Some(artifact.file_path.to_string())
+        },
+        target_triples: artifact.target_triples.clone(),
+        install_hint,
+        description,
+        assets,
+        kind,
+        checksum,
+    }
+}
diff --git a/cargo-dist/src/tasks.rs b/cargo-dist/src/tasks.rs
index 38392670..7c48cd11 100644
--- a/cargo-dist/src/tasks.rs
+++ b/cargo-dist/src/tasks.rs
@@ -48,17 +48,16 @@
 //! 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 itertools::Itertools;
-use std::process::Command;
-
-use axoproject::platforms::triple_to_display_name;
 use axoproject::{PackageId, PackageIdx, WorkspaceInfo};
 use camino::Utf8PathBuf;
+use cargo_dist_schema::{DistManifest, Hosting};
 use cruet::to_class_case;
 use miette::{miette, Context, IntoDiagnostic};
 use semver::Version;
+use std::process::Command;
 use tracing::{info, warn};
 
+use crate::announce::{self, AnnouncementTag};
 use crate::backend::ci::github::GithubCiInfo;
 use crate::backend::ci::CiInfo;
 use crate::config::{DependencyKind, DirtyMode, ProductionMode, SystemDependencies};
@@ -171,21 +170,6 @@ pub struct DistGraph {
     pub pr_run_mode: cargo_dist_schema::PrRunMode,
     /// Generate targets to skip configuration up to date checks for
     pub allow_dirty: DirtyMode,
-    /// The git tag used for the announcement (e.g. v1.0.0)
-    ///
-    /// This is important for certain URLs like Github Releases
-    pub announcement_tag: Option<String>,
-    /// Whether the announcement appears to be a prerelease
-    pub announcement_is_prerelease: bool,
-    /// Title of the announcement
-    pub announcement_title: Option<String>,
-    /// Raw changelog for the announcement
-    pub announcement_changelog: Option<String>,
-    /// Github Releases body for the announcement
-    pub announcement_github_body: Option<String>,
-    /// Base URL that artifacts are downloadable from ("{artifact_download_url}/{artifact.id}")
-    pub artifact_download_url: Option<String>,
-
     /// Targets we need to build (local artifacts)
     pub local_build_steps: Vec<BuildStep>,
     /// Targets we need to build (global artifacts)
@@ -204,8 +188,6 @@ pub struct DistGraph {
     pub publish_jobs: Vec<PublishStyle>,
     /// Extra user-specified publish jobs to run
     pub user_publish_jobs: Vec<String>,
-    /// Whether to publish prerelease builds to package managers
-    pub publish_prereleases: bool,
     /// A GitHub repo to publish the Homebrew formula to
     pub tap: Option<String>,
     /// Whether msvc targets should statically link the crt
@@ -581,6 +563,7 @@ pub enum CargoTargetPackages {
 
 pub(crate) struct DistGraphBuilder<'pkg_graph> {
     pub(crate) inner: DistGraph,
+    pub(crate) manifest: DistManifest,
     pub(crate) workspace: &'pkg_graph WorkspaceInfo,
     artifact_mode: ArtifactMode,
     binaries_by_id: FastMap<String, BinaryIdx>,
@@ -675,6 +658,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         let create_release = create_release.unwrap_or(true);
         let msvc_crt_static = msvc_crt_static.unwrap_or(true);
         let ssldotcom_windows_sign = ssldotcom_windows_sign.clone();
+
         let mut packages_with_mismatched_features = vec![];
         // Compute/merge package configs
         let mut package_metadata = vec![];
@@ -739,6 +723,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         } else {
             DirtyMode::AllowList(allow_dirty.clone().unwrap_or(vec![]))
         };
+        let cargo_version_line = tools.cargo.version_line.clone();
 
         Ok(Self {
             inner: DistGraph {
@@ -755,12 +740,6 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 desired_rust_toolchain,
                 tools,
                 templates,
-                announcement_tag: None,
-                announcement_is_prerelease: false,
-                announcement_changelog: None,
-                announcement_github_body: None,
-                announcement_title: None,
-                artifact_download_url: None,
                 ci_style: vec![],
                 local_build_steps: vec![],
                 global_build_steps: vec![],
@@ -773,10 +752,24 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 tap: workspace_metadata.tap.clone(),
                 publish_jobs,
                 user_publish_jobs,
-                publish_prereleases,
                 allow_dirty,
                 msvc_crt_static,
             },
+            manifest: DistManifest {
+                dist_version: Some(env!("CARGO_PKG_VERSION").to_owned()),
+                system_info: Some(cargo_dist_schema::SystemInfo { cargo_version_line }),
+                announcement_tag: None,
+                announcement_is_prerelease: false,
+                announcement_title: None,
+                announcement_changelog: None,
+                announcement_github_body: None,
+                releases: vec![],
+                artifacts: Default::default(),
+                publish_prereleases,
+                ci: None,
+                linkage: vec![],
+                hosting: None,
+            },
             package_metadata,
             workspace_metadata,
             workspace,
@@ -785,7 +778,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         })
     }
 
-    fn package_metadata(&self, idx: PackageIdx) -> &DistMetadata {
+    pub(crate) fn package_metadata(&self, idx: PackageIdx) -> &DistMetadata {
         &self.package_metadata[idx.0]
     }
 
@@ -1183,7 +1176,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         }
         let release = self.release(to_release);
         let release_id = &release.id;
-        let Some(download_url) = &self.inner.artifact_download_url else {
+        let Some(download_url) = self.manifest.artifact_download_url() else {
             warn!("skipping shell installer: couldn't compute a URL to download artifacts from");
             return;
         };
@@ -1300,7 +1293,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 app_name: release.app_name.clone(),
                 app_version: release.version.to_string(),
                 install_path: release.install_path.clone().into_jinja(),
-                base_url: download_url.clone(),
+                base_url: download_url.to_owned(),
                 artifacts,
                 hint,
                 desc,
@@ -1317,7 +1310,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         }
         let release = self.release(to_release);
         let release_id = &release.id;
-        let Some(download_url) = &self.inner.artifact_download_url else {
+        let Some(download_url) = self.manifest.artifact_download_url() else {
             warn!("skipping Homebrew formula: couldn't compute a URL to download artifacts from");
             return;
         };
@@ -1454,7 +1447,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                     app_name: release.app_name.clone(),
                     app_version: release.version.to_string(),
                     install_path: release.install_path.clone().into_jinja(),
-                    base_url: download_url.clone(),
+                    base_url: download_url.to_owned(),
                     artifacts,
                     hint,
                     desc,
@@ -1474,7 +1467,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         // Get the basic info about the installer
         let release = self.release(to_release);
         let release_id = &release.id;
-        let Some(download_url) = &self.inner.artifact_download_url else {
+        let Some(download_url) = self.manifest.artifact_download_url() else {
             warn!(
                 "skipping powershell installer: couldn't compute a URL to download artifacts from"
             );
@@ -1528,7 +1521,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 app_name: release.app_name.clone(),
                 app_version: release.version.to_string(),
                 install_path: release.install_path.clone().into_jinja(),
-                base_url: download_url.clone(),
+                base_url: download_url.to_owned(),
                 artifacts,
                 hint,
                 desc,
@@ -1545,7 +1538,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         }
         let release = self.release(to_release);
         let release_id = &release.id;
-        let Some(download_url) = &self.inner.artifact_download_url else {
+        let Some(download_url) = self.manifest.artifact_download_url() else {
             warn!("skipping npm installer: couldn't compute a URL to download artifacts from");
             return;
         };
@@ -1705,7 +1698,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                     app_name: release.app_name.clone(),
                     app_version: release.version.to_string(),
                     install_path: release.install_path.clone().into_jinja(),
-                    base_url: download_url.clone(),
+                    base_url: download_url.to_owned(),
                     artifacts,
                     hint,
                     desc,
@@ -1929,305 +1922,96 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         }
     }
 
-    fn compute_cargo_builds(&mut self) -> Vec<BuildStep> {
-        // For now we can be really simplistic and just do a workspace build for every
-        // target-triple we have a binary-that-needs-a-real-build for.
-        let mut targets = SortedMap::<TargetTriple, Vec<BinaryIdx>>::new();
-        for (binary_idx, binary) in self.inner.binaries.iter().enumerate() {
-            if !binary.copy_exe_to.is_empty() || !binary.copy_symbols_to.is_empty() {
-                targets
-                    .entry(binary.target.clone())
-                    .or_default()
-                    .push(BinaryIdx(binary_idx));
-            }
+    fn compute_hosting(&mut self, _cfg: &Config, announcing: &AnnouncementTag) -> Result<()> {
+        // If this was already provided by a merged dist-manifest, don't redo it
+        if self.manifest.hosting.is_some() {
+            return Ok(());
         }
 
-        let mut builds = vec![];
-        for (target, binaries) in targets {
-            let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
+        if let Some(repo_url) = self.workspace().web_url()?.as_ref() {
+            let tag = &announcing.tag;
+            self.manifest.hosting = Some(Hosting {
+                staging_artifacts_url: None,
+                live_artifacts_url: Some(format!("{repo_url}/releases/download/{tag}")),
+                upload_url: None,
+                publish_url: None,
+                announce_url: None,
+            });
+        }
+        Ok(())
+    }
 
-            // FIXME: is there a more principled way for us to add things to RUSTFLAGS
-            // without breaking everything. Cargo has some builtin ways like keys
-            // in [target...] tables that will get "merged" with the flags it wants
-            // to set. More blunt approaches like actually setting the environment
-            // variable I think can result in overwriting flags other places set
-            // (which is defensible, having spaghetti flags randomly injected by
-            // a dozen different tools is a build maintenance nightmare!)
+    fn compute_releases(
+        &mut self,
+        cfg: &Config,
+        announcing: &AnnouncementTag,
+        triples: &[String],
+        bypass_package_target_prefs: bool,
+    ) -> DistResult<()> {
+        // Create a Release for each package
+        for (pkg_idx, binaries) in &announcing.rust_releases {
+            // FIXME: this clone is hacky but I'm in the middle of a nasty refactor
+            let package_config = self.package_metadata(*pkg_idx).clone();
 
-            // You're *supposed* to link libc statically on windows but Rust has a bad default.
-            // See: https://rust-lang.github.io/rfcs/1721-crt-static.html
-            //
-            // ... well ok it's actually more complicated than that. Most rust applications
-            // don't dynamically link anything non-trivial, so statically linking libc is fine.
-            // However if you need to dynamically link stuff there starts to be issues about
-            // agreeing to the crt in play. At that point you should ship a
-            // Visual C(++) Redistributable that installs the version of the runtime you depend
-            // on. Not doing that is basically rolling some dice and hoping the user already
-            // has it installed, which isn't great. We should support redists eventually,
-            // but for now this hacky global flag is here to let you roll dice.
-            if self.inner.msvc_crt_static && target.contains("windows-msvc") {
-                rustflags.push_str(" -Ctarget-feature=+crt-static");
-            }
+            // Create a Release for this binary
+            let release = self.add_release(*pkg_idx);
 
-            // Likewise, the default for musl will change in the future, so
-            // we can future-proof this by adding the flag now
-            // See: https://github.com/axodotdev/cargo-dist/issues/486
-            if target.ends_with("linux-musl") {
-                rustflags.push_str(" -Ctarget-feature=+crt-static -Clink-self-contained=yes");
+            // Tell the Release to include these binaries
+            for binary in binaries {
+                self.add_binary(release, *pkg_idx, (*binary).clone());
             }
 
-            // If we're trying to cross-compile on macOS, ensure the rustup toolchain
-            // is setup!
-            if target.ends_with("apple-darwin")
-                && self.inner.tools.cargo.host_target.ends_with("apple-darwin")
-                && target != self.inner.tools.cargo.host_target
-            {
-                if let Some(rustup) = self.inner.tools.rustup.clone() {
-                    builds.push(BuildStep::Rustup(RustupStep {
-                        rustup,
-                        target: target.clone(),
-                    }));
-                } else {
-                    warn!("You're trying to cross-compile on macOS, but I can't find rustup to ensure you have the rust toolchains for it!")
+            // Create variants for this Release for each target
+            for target in triples {
+                // This logic ensures that (outside of host mode) we only select targets that are a
+                // subset of the ones the package claims to support
+                let use_target = bypass_package_target_prefs
+                    || package_config
+                        .targets
+                        .as_deref()
+                        .unwrap_or_default()
+                        .iter()
+                        .any(|t| t == target);
+                if !use_target {
+                    continue;
                 }
-            }
 
-            if target.ends_with("linux-musl")
-                && self.inner.tools.cargo.host_target.ends_with("linux-gnu")
-            {
-                if let Some(rustup) = self.inner.tools.rustup.clone() {
-                    builds.push(BuildStep::Rustup(RustupStep {
-                        rustup,
-                        target: target.clone(),
-                    }));
-                } else {
-                    warn!("You're trying to cross-compile for musl from glibc, but I can't find rustup to ensure you have the rust toolchains for it!")
-                }
+                // Create the variant
+                self.add_variant(release, target.clone());
             }
+            // Add executable zips to the Release
+            self.add_executable_zip(release);
 
-            if self.inner.precise_builds {
-                // `(target, package, features)` uniquely identifies a build we need to do,
-                // so group all the binaries under those buckets and add a build for each one
-                // (targets is handled by the loop we're in)
-                let mut builds_by_pkg_spec = SortedMap::new();
-                for bin_idx in binaries {
-                    let bin = self.binary(bin_idx);
-                    builds_by_pkg_spec
-                        .entry((bin.pkg_spec.clone(), bin.features.clone()))
-                        .or_insert(vec![])
-                        .push(bin_idx);
-                }
-                for ((pkg_spec, features), expected_binaries) in builds_by_pkg_spec {
-                    builds.push(BuildStep::Cargo(CargoBuildStep {
-                        target_triple: target.clone(),
-                        package: CargoTargetPackages::Package(pkg_spec),
-                        features,
-                        rustflags: rustflags.clone(),
-                        profile: String::from(PROFILE_DIST),
-                        expected_binaries,
-                    }));
-                }
+            // Add installers to the Release
+            // Prefer the CLI's choices (`cfg`) if they're non-empty
+            let installers = if cfg.installers.is_empty() {
+                package_config.installers.as_deref().unwrap_or_default()
             } else {
-                // If we think a workspace build is possible, every binary agrees on the features, so take an arbitrary one
-                let features = binaries
-                    .first()
-                    .map(|&idx| self.binary(idx).features.clone())
-                    .unwrap_or_default();
-                builds.push(BuildStep::Cargo(CargoBuildStep {
-                    target_triple: target.clone(),
-                    package: CargoTargetPackages::Workspace,
-                    features,
-                    rustflags,
-                    profile: String::from(PROFILE_DIST),
-                    expected_binaries: binaries,
-                }));
-            }
-        }
-        builds
-    }
-
-    fn compute_announcement_info(&mut self, announcing: &AnnouncementTag) {
-        // Default to using the tag as a title
-        self.inner.announcement_title = Some(announcing.tag.clone());
-        self.inner.announcement_tag = Some(announcing.tag.clone());
-        self.inner.announcement_is_prerelease = announcing.prerelease;
-
-        self.compute_announcement_changelog(announcing);
-        self.compute_announcement_github();
-    }
-
-    /// Try to compute changelogs for the announcement
-    pub fn compute_announcement_changelog(&mut self, announcing: &AnnouncementTag) {
-        let info = if let Some(announcing_version) = &announcing.version {
-            // Try to find the version we're announcing in the top level CHANGELOG/RELEASES
-            let version = axoproject::Version::Cargo(announcing_version.clone());
-            let Ok(Some(info)) = self.workspace.changelog_for_version(&version) else {
-                info!(
-                    "failed to find {version} in workspace changelogs, skipping changelog generation"
-                );
-                return;
-            };
-
-            info
-        } else if let Some(announcing_package) = announcing.package {
-            // Try to find the package's specific CHANGELOG/RELEASES
-            let package = self.workspace.package(announcing_package);
-            let package_name = &package.name;
-            let version = package
-                .version
-                .as_ref()
-                .expect("cargo package without a version!?");
-            let Ok(Some(info)) = self
-                .workspace
-                .package(announcing_package)
-                .changelog_for_version(version)
-            else {
-                info!(
-                    "failed to find {version} in {package_name} changelogs, skipping changelog generation"
-                );
-                return;
+                &cfg.installers[..]
             };
 
-            info
-        } else {
-            unreachable!("you're neither announcing a version or a package!?");
-        };
-
-        info!("successfully parsed changelog!");
-        self.inner.announcement_title = Some(info.title);
-        // Those windows newlines get everywhere...
-        let clean_notes = newline_converter::dos2unix(&info.body);
-        self.inner.announcement_changelog = Some(clean_notes.into_owned());
-    }
-
-    /// If we're publishing to Github, generate some Github notes
-    fn compute_announcement_github(&mut self) {
-        use std::fmt::Write;
-
-        if !self.inner.ci_style.contains(&CiStyle::Github) {
-            info!("not publishing to Github, skipping Github Release Notes");
-            return;
-        }
-
-        let mut gh_body = String::new();
-        let download_url = self.inner.artifact_download_url.as_ref();
-
-        // add release notes
-        if let Some(changelog) = self.inner.announcement_changelog.as_ref() {
-            gh_body.push_str("## Release Notes\n\n");
-            gh_body.push_str(changelog);
-            gh_body.push_str("\n\n");
-        }
-
-        // Add the contents of each Release to the body
-        for release in &self.inner.releases {
-            let heading_suffix = format!("{} {}", release.app_name, release.version);
-
-            // Delineate releases if there's more than 1
-            if self.inner.releases.len() > 1 {
-                writeln!(gh_body, "# {heading_suffix}\n").unwrap();
-            }
-
-            // Sort out all the artifacts in this Release
-            let mut global_installers = vec![];
-            let mut local_installers = vec![];
-            let mut bundles = vec![];
-            let mut symbols = vec![];
-
-            for &artifact_idx in &release.global_artifacts {
-                let artifact = self.artifact(artifact_idx);
-                match &artifact.kind {
-                    ArtifactKind::ExecutableZip(zip) => bundles.push((artifact, zip)),
-                    ArtifactKind::Symbols(syms) => symbols.push((artifact, syms)),
-                    ArtifactKind::Checksum(_) => {}
-                    ArtifactKind::Installer(installer) => {
-                        global_installers.push((artifact, installer))
-                    }
-                }
-            }
-
-            for &variant_idx in &release.variants {
-                let variant = self.variant(variant_idx);
-                for &artifact_idx in &variant.local_artifacts {
-                    let artifact = self.artifact(artifact_idx);
-                    match &artifact.kind {
-                        ArtifactKind::ExecutableZip(zip) => bundles.push((artifact, zip)),
-                        ArtifactKind::Symbols(syms) => symbols.push((artifact, syms)),
-                        ArtifactKind::Checksum(_) => {}
-                        ArtifactKind::Installer(installer) => {
-                            local_installers.push((artifact, installer))
-                        }
-                    }
-                }
-            }
-
-            if !global_installers.is_empty() {
-                writeln!(gh_body, "## Install {heading_suffix}\n").unwrap();
-                for (_installer, details) in global_installers {
-                    let info = match details {
-                        InstallerImpl::Shell(info)
-                        | InstallerImpl::Homebrew(HomebrewInstallerInfo { inner: info, .. })
-                        | InstallerImpl::Powershell(info)
-                        | InstallerImpl::Npm(NpmInstallerInfo { inner: info, .. }) => info,
-                        InstallerImpl::Msi(_) => {
-                            // Should be unreachable, but let's not crash over it
-                            continue;
-                        }
-                    };
-                    writeln!(&mut gh_body, "### {}\n", info.desc).unwrap();
-                    writeln!(&mut gh_body, "```sh\n{}\n```\n", info.hint).unwrap();
+            for installer in installers {
+                // This logic ensures that (outside of host mode) we only select installers that are a
+                // subset of the ones the package claims to support
+                let use_installer = package_config
+                    .installers
+                    .as_deref()
+                    .unwrap_or_default()
+                    .iter()
+                    .any(|i| i == installer);
+                if !use_installer {
+                    continue;
                 }
-            }
 
-            let other_artifacts: Vec<_> = bundles
-                .iter()
-                .map(|i| i.0)
-                .chain(local_installers.iter().map(|i| i.0))
-                .chain(symbols.iter().map(|i| i.0))
-                .collect();
-            if !other_artifacts.is_empty() && download_url.is_some() {
-                let download_url = download_url.as_ref().unwrap();
-                writeln!(gh_body, "## Download {heading_suffix}\n",).unwrap();
-                gh_body.push_str("|  File  | Platform | Checksum |\n");
-                gh_body.push_str("|--------|----------|----------|\n");
-
-                for artifact in other_artifacts {
-                    let mut targets = String::new();
-                    let mut multi_target = false;
-                    for target in &artifact.target_triples {
-                        if multi_target {
-                            targets.push_str(", ");
-                        }
-                        targets.push_str(target);
-                        multi_target = true;
-                    }
-                    let name = &artifact.id;
-                    let artifact_download_url = format!("{download_url}/{name}");
-                    let download = format!("[{name}]({artifact_download_url})");
-                    let checksum = if let Some(checksum_idx) = artifact.checksum {
-                        let checksum_name = &self.artifact(checksum_idx).id;
-                        let checksum_download_url = format!("{download_url}/{checksum_name}");
-                        format!("[checksum]({checksum_download_url})")
-                    } else {
-                        String::new()
-                    };
-                    let mut triple = artifact
-                        .target_triples
-                        .iter()
-                        .filter_map(|t| triple_to_display_name(t))
-                        .join(", ");
-                    if triple.is_empty() {
-                        triple = "Unknown".to_string();
-                    }
-                    writeln!(&mut gh_body, "| {download} | {triple} | {checksum} |").unwrap();
-                }
-                writeln!(&mut gh_body).unwrap();
+                // Create the variant
+                self.add_installer(release, installer)?;
             }
         }
 
-        info!("successfully generated github release body!");
-        // self.inner.artifact_download_url = Some(download_url);
-        self.inner.announcement_github_body = Some(gh_body);
+        // Translate the result to DistManifest
+        crate::manifest::add_releases_to_manifest(cfg, &self.inner, &mut self.manifest)?;
+
+        Ok(())
     }
 
     fn compute_ci(&mut self) {
@@ -2238,36 +2022,47 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 }
             }
         }
+
+        // apply to manifest
+        if !self.inner.ci_style.is_empty() {
+            let CiInfo { github } = &self.inner.ci;
+            let github = github.as_ref().map(|info| cargo_dist_schema::GithubCiInfo {
+                artifacts_matrix: Some(info.artifacts_matrix.clone()),
+                pr_run_mode: Some(info.pr_run_mode),
+            });
+
+            self.manifest.ci = Some(cargo_dist_schema::CiInfo { github });
+        }
     }
 
-    fn workspace(&self) -> &'pkg_graph WorkspaceInfo {
+    pub(crate) fn workspace(&self) -> &'pkg_graph WorkspaceInfo {
         self.workspace
     }
-    fn binary(&self, idx: BinaryIdx) -> &Binary {
+    pub(crate) fn binary(&self, idx: BinaryIdx) -> &Binary {
         &self.inner.binaries[idx.0]
     }
-    fn binary_mut(&mut self, idx: BinaryIdx) -> &mut Binary {
+    pub(crate) fn binary_mut(&mut self, idx: BinaryIdx) -> &mut Binary {
         &mut self.inner.binaries[idx.0]
     }
-    fn artifact(&self, idx: ArtifactIdx) -> &Artifact {
+    pub(crate) fn artifact(&self, idx: ArtifactIdx) -> &Artifact {
         &self.inner.artifacts[idx.0]
     }
-    fn artifact_mut(&mut self, idx: ArtifactIdx) -> &mut Artifact {
+    pub(crate) fn artifact_mut(&mut self, idx: ArtifactIdx) -> &mut Artifact {
         &mut self.inner.artifacts[idx.0]
     }
-    fn release(&self, idx: ReleaseIdx) -> &Release {
+    pub(crate) fn release(&self, idx: ReleaseIdx) -> &Release {
         &self.inner.releases[idx.0]
     }
-    fn release_mut(&mut self, idx: ReleaseIdx) -> &mut Release {
+    pub(crate) fn release_mut(&mut self, idx: ReleaseIdx) -> &mut Release {
         &mut self.inner.releases[idx.0]
     }
-    fn variant(&self, idx: ReleaseVariantIdx) -> &ReleaseVariant {
+    pub(crate) fn variant(&self, idx: ReleaseVariantIdx) -> &ReleaseVariant {
         &self.inner.variants[idx.0]
     }
-    fn variant_mut(&mut self, idx: ReleaseVariantIdx) -> &mut ReleaseVariant {
+    pub(crate) fn variant_mut(&mut self, idx: ReleaseVariantIdx) -> &mut ReleaseVariant {
         &mut self.inner.variants[idx.0]
     }
-    fn local_artifacts_enabled(&self) -> bool {
+    pub(crate) fn local_artifacts_enabled(&self) -> bool {
         match self.artifact_mode {
             ArtifactMode::Local => true,
             ArtifactMode::Global => false,
@@ -2275,7 +2070,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
             ArtifactMode::All => true,
         }
     }
-    fn global_artifacts_enabled(&self) -> bool {
+    pub(crate) fn global_artifacts_enabled(&self) -> bool {
         match self.artifact_mode {
             ArtifactMode::Local => false,
             ArtifactMode::Global => true,
@@ -2305,13 +2100,18 @@ impl DistGraph {
 }
 
 /// Precompute all the work this invocation will need to do
-pub fn gather_work(cfg: &Config) -> Result<DistGraph> {
+pub fn gather_work(cfg: &Config) -> Result<(DistGraph, DistManifest)> {
     info!("analyzing workspace:");
     let tools = tool_info()?;
     let workspace = crate::config::get_project()?;
     let mut graph =
         DistGraphBuilder::new(tools, &workspace, cfg.artifact_mode, cfg.allow_all_dirty)?;
 
+    // Immediately check if there's other manifests kicking around that provide info
+    // we don't want to recompute (lets us move towards more of an architecture where
+    // `plan` figures out what to do and subsequent steps Simply Obey).
+    crate::manifest::load_and_merge_manifests(&graph.inner.dist_dir, &mut graph.manifest)?;
+
     // Prefer the CLI (cfg) if it's non-empty, but only select a subset
     // of what the workspace supports if it's non-empty
     let workspace_ci = graph.workspace_metadata.ci.clone().unwrap_or_default();
@@ -2363,76 +2163,18 @@ pub fn gather_work(cfg: &Config) -> Result<DistGraph> {
     };
     info!("selected triples: {:?}", triples);
 
-    let announcing = select_tag(
+    // Figure out what packages we're announcing
+    let announcing = announce::select_tag(
         &graph,
         cfg.announcement_tag.as_deref(),
         cfg.needs_coherent_announcement_tag,
     )?;
 
-    if let Some(repo_url) = workspace.web_url()?.as_ref() {
-        let tag = &announcing.tag;
-        graph.inner.artifact_download_url = Some(format!("{repo_url}/releases/download/{tag}"));
-    }
-
-    // Create a Release for each package
-    for (pkg_idx, binaries) in &announcing.rust_releases {
-        // FIXME: this clone is hacky but I'm in the middle of a nasty refactor
-        let package_config = graph.package_metadata(*pkg_idx).clone();
-
-        // Create a Release for this binary
-        let release = graph.add_release(*pkg_idx);
-
-        // Tell the Release to include these binaries
-        for binary in binaries {
-            graph.add_binary(release, *pkg_idx, (*binary).clone());
-        }
-
-        // Create variants for this Release for each target
-        for target in triples {
-            // This logic ensures that (outside of host mode) we only select targets that are a
-            // subset of the ones the package claims to support
-            let use_target = bypass_package_target_prefs
-                || package_config
-                    .targets
-                    .as_deref()
-                    .unwrap_or_default()
-                    .iter()
-                    .any(|t| t == target);
-            if !use_target {
-                continue;
-            }
-
-            // Create the variant
-            graph.add_variant(release, target.clone());
-        }
-        // Add executable zips to the Release
-        graph.add_executable_zip(release);
-
-        // Add installers to the Release
-        // Prefer the CLI's choices (`cfg`) if they're non-empty
-        let installers = if cfg.installers.is_empty() {
-            package_config.installers.as_deref().unwrap_or_default()
-        } else {
-            &cfg.installers[..]
-        };
-
-        for installer in installers {
-            // This logic ensures that (outside of host mode) we only select installers that are a
-            // subset of the ones the package claims to support
-            let use_installer = package_config
-                .installers
-                .as_deref()
-                .unwrap_or_default()
-                .iter()
-                .any(|i| i == installer);
-            if !use_installer {
-                continue;
-            }
+    // Figure out how artifacts should be hosted
+    graph.compute_hosting(cfg, &announcing)?;
 
-            // Create the variant
-            graph.add_installer(release, installer)?;
-        }
-    }
+    // Figure out what we're releasing/building
+    graph.compute_releases(cfg, &announcing, triples, bypass_package_target_prefs)?;
 
     // Prep the announcement's release notes and whatnot
     graph.compute_announcement_info(&announcing);
@@ -2440,71 +2182,10 @@ pub fn gather_work(cfg: &Config) -> Result<DistGraph> {
     // Finally compute all the build steps!
     graph.compute_build_steps();
 
+    // And now figure out how to orchestrate the result in CI
     graph.compute_ci();
 
-    Ok(graph.inner)
-}
-
-/// See if we should dist this package.
-///
-/// Some(disabled_reason) is returned if it shouldn't be.
-///
-/// This code is written to assume a package and its binaries should be distable,
-/// and then runs through a battery of disqualifying reasons.
-///
-/// A notable consequence of this is that if --tag wasn't passed, then we will default to
-/// letting through all the packages that aren't intrinsically disqualified by things like
-/// publish=false. Later steps will then check if a coherent announcement tag exists that
-/// covers everything this function spat out.
-fn check_dist_package(
-    graph: &DistGraphBuilder,
-    pkg_id: PackageIdx,
-    pkg: &axoproject::PackageInfo,
-    announcing: &PartialAnnouncementTag,
-) -> Option<String> {
-    // Nothing to publish if there's no binaries!
-    if pkg.binaries.is_empty() {
-        return Some("no binaries".to_owned());
-    }
-
-    // If [metadata.dist].dist is explicitly set, respect it!
-    let override_publish = if let Some(do_dist) = graph.package_metadata(pkg_id).dist {
-        if !do_dist {
-            return Some("dist = false".to_owned());
-        } else {
-            true
-        }
-    } else {
-        false
-    };
-
-    // Otherwise defer to Cargo's `publish = false`
-    if !pkg.publish && !override_publish {
-        return Some("publish = false".to_owned());
-    }
-
-    // If we're announcing a package, reject every other package
-    if let Some(id) = announcing.package {
-        if pkg_id != id {
-            return Some(format!(
-                "didn't match tag {}",
-                announcing.tag.as_ref().unwrap()
-            ));
-        }
-    }
-
-    // If we're announcing a version, ignore everything that doesn't match that
-    if let Some(ver) = &announcing.version {
-        if pkg.version.as_ref().unwrap().cargo() != ver {
-            return Some(format!(
-                "didn't match tag {}",
-                announcing.tag.as_ref().unwrap()
-            ));
-        }
-    }
-
-    // If it passes the guantlet, dist it
-    None
+    Ok((graph.inner, graph.manifest))
 }
 
 /// Get the path/command to invoke Cargo
@@ -2585,352 +2266,3 @@ fn find_tool(name: &str, test_flag: &str) -> Option<Tool> {
         version: version.to_owned(),
     })
 }
-
-/// details on what we're announcing
-pub(crate) struct AnnouncementTag {
-    /// The full tag
-    pub tag: String,
-    /// The version we're announcing (if doing a unified version announcement)
-    pub version: Option<Version>,
-    /// The package we're announcing (if doing a single-package announcement)
-    pub package: Option<PackageIdx>,
-    /// whether we're prereleasing
-    pub prerelease: bool,
-    /// Which packages+bins we're announcing
-    pub rust_releases: Vec<(PackageIdx, Vec<String>)>,
-}
-
-/// details on what we're announcing (partially computed)
-struct PartialAnnouncementTag {
-    /// The full tag
-    pub tag: Option<String>,
-    /// The version we're announcing (if doing a unified version announcement)
-    pub version: Option<Version>,
-    /// The package we're announcing (if doing a single-package announcement)
-    pub package: Option<PackageIdx>,
-    /// whether we're prereleasing
-    pub prerelease: bool,
-}
-
-/// Parse the announcement tag and determine what we're announcing
-///
-/// `tag` being None here is equivalent to `--tag` not being passed, and tells us to infer
-/// the tag based on things like "every package has the same version, assume we're
-/// announcing that version".
-///
-/// `needs_coherent_announcement_tag = false` tells us to produce a result even if inference
-/// fails to find a tag that will unambiguously work. This is used by commands like `init`
-/// and `generate` which want to consider "everything" even if the user never actually
-/// could announce everything at once. In this case dummy values will appear in every field
-/// except for `AnnouncementTag::rust_releases` which will contain every distable binary
-/// in the workspace.
-pub(crate) fn select_tag(
-    graph: &DistGraphBuilder,
-    tag: Option<&str>,
-    needs_coherent_announcement_tag: bool,
-) -> DistResult<AnnouncementTag> {
-    // Parse the tag
-    let mut announcing = parse_tag(graph, tag)?;
-    // Select which packages/binaries are available from that tag
-    let rust_releases = select_packages(graph, &announcing);
-
-    // Don't proceed if the conclusions don't make sense
-    if rust_releases.is_empty() {
-        // It's ok for there to be no selected binaries if the user explicitly requested an
-        // announcement for a library with `--tag=my-lib-1.0.0`
-        if announcing.package.is_some() {
-            warn!("You're trying to explicitly Release a library, only minimal functionality will work");
-        } else {
-            // No binaries were selected, and they weren't trying to announce a library,
-            // we've gotta bail out, this is too weird.
-            //
-            // To get better help messages, we explore a hypothetical world where they didn't pass
-            // `--tag` so we can get all the options for a good help message.
-            let announcing = parse_tag(graph, None)?;
-            let rust_releases = select_packages(graph, &announcing);
-            let versions = possible_tags(graph, rust_releases.iter().map(|(idx, _)| *idx));
-            let help = tag_help(graph, versions, "You may need to pass the current version as --tag, or need to give all your packages the same version");
-            return Err(DistError::NothingToRelease { help });
-        }
-    }
-
-    // If we don't have a tag yet we MUST successfully select one here or fail
-    if announcing.tag.is_none() {
-        // Group distable packages by version, if there's only one then use that as the tag
-        let versions = possible_tags(graph, rust_releases.iter().map(|(idx, _)| *idx));
-        if versions.len() == 1 {
-            // Nice, one version, use it
-            let version = *versions.first_key_value().unwrap().0;
-            let tag = format!("v{version}");
-            info!("inferred Announcement tag: {}", tag);
-            announcing.tag = Some(tag);
-            announcing.prerelease = !version.pre.is_empty();
-            announcing.version = Some(version.clone());
-        } else if needs_coherent_announcement_tag {
-            // More than one version, give the user some suggestions
-            let help = tag_help(
-                graph,
-                versions,
-                "Please either specify --tag, or give them all the same version",
-            );
-            return Err(DistError::TooManyUnrelatedApps { help });
-        } else {
-            // Ok we got more than one version but we're being run by a command
-            // like `init` or `generate` which just wants us to hand it everything
-            // and doesn't care about coherent announcements. So use a fake tag
-            // and hand it the fully unconstrained list of rust_releases.
-            announcing.tag = Some("v1.0.0-FAKEVER".to_owned());
-            announcing.prerelease = true;
-            announcing.version = Some("1.0.0-FAKEVER".parse().unwrap());
-        }
-    }
-    Ok(AnnouncementTag {
-        tag: announcing
-            .tag
-            .expect("integrity error: failed to select announcement tag"),
-        version: announcing.version,
-        package: announcing.package,
-        prerelease: announcing.prerelease,
-        rust_releases,
-    })
-}
-
-/// Do the actual parsing logic for a tag
-///
-/// If `tag` is None, then we had no --tag to parse, and need to do inference.
-/// The return value is then essentially a default/empty PartialAnnouncementTag
-/// which later passes will fill in.
-fn parse_tag(graph: &DistGraphBuilder, tag: Option<&str>) -> DistResult<PartialAnnouncementTag> {
-    // First thing's first: if they gave us an announcement tag then we should try to parse it
-    let mut announcing_package = None;
-    let mut announcing_version = None;
-    let mut announcing_prerelease = false;
-    let announcement_tag = tag.map(|t| t.to_owned());
-    if let Some(tag) = &announcement_tag {
-        let mut tag_suffix;
-        // Check if we're using `/`'s to delimit things
-        if let Some((prefix, suffix)) = tag.rsplit_once('/') {
-            // We're at least in "blah/v1.0.0" format
-            let maybe_package = if let Some((_prefix, package)) = prefix.rsplit_once('/') {
-                package
-            } else {
-                // There's only one `/`, assume the whole prefix could be a package name
-                prefix
-            };
-            // Check if this is "blah/blah/some-package/v1.0.0" format by checking if the last slash-delimited
-            // component is exactly a package name (strip_prefix produces empty string)
-            if let Some((package, "")) = strip_prefix_package(maybe_package, graph) {
-                announcing_package = Some(package);
-            }
-            tag_suffix = suffix;
-        } else {
-            tag_suffix = tag;
-        };
-
-        // If we don't have an announcing_package yet, check if this is "some-package-v1.0.0" format
-        if announcing_package.is_none() {
-            if let Some((package, suffix)) = strip_prefix_package(tag_suffix, graph) {
-                // Must be followed by a dash to be accepted
-                if let Some(suffix) = suffix.strip_prefix('-') {
-                    tag_suffix = suffix;
-                    announcing_package = Some(package);
-                }
-            }
-        }
-
-        // At this point, assuming the input is valid, tag_suffix should just be the version
-        // component with an optional "v" prefix, so strip that "v"
-        if let Some(suffix) = tag_suffix.strip_prefix('v') {
-            tag_suffix = suffix;
-        }
-
-        // Now parse the version out
-        match tag_suffix.parse::<Version>() {
-            Ok(version) => {
-                // Register whether we're announcing a prerelease
-                announcing_prerelease = !version.pre.is_empty();
-
-                // If there's an announcing package, validate that the version matches
-                if let Some(pkg_idx) = announcing_package {
-                    let package = graph.workspace().package(pkg_idx);
-                    if let Some(real_version) = &package.version {
-                        if real_version.cargo() != &version {
-                            return Err(DistError::ContradictoryTagVersion {
-                                tag: tag.clone(),
-                                package_name: package.name.clone(),
-                                tag_version: version,
-                                real_version: real_version.clone(),
-                            });
-                        }
-                    }
-                } else {
-                    // We had no announcing_package, so looks like we're doing a unified release.
-                    // Set this value to indicate that.
-                    announcing_version = Some(version);
-                }
-            }
-            Err(e) => {
-                return Err(DistError::TagVersionParse {
-                    tag: tag.clone(),
-                    details: e,
-                })
-            }
-        }
-
-        // If none of the approaches work, refuse to proceed
-        if announcing_package.is_none() && announcing_version.is_none() {
-            return Err(DistError::NoTagMatch { tag: tag.clone() });
-        }
-    }
-    Ok(PartialAnnouncementTag {
-        tag: announcement_tag,
-        prerelease: announcing_prerelease,
-        version: announcing_version,
-        package: announcing_package,
-    })
-}
-
-/// Select which packages/binaries the announcement includes and print info about the process
-///
-/// See `check_dist_package` for the actual selection logic and some notes on inference
-/// when `--tag` is absent.
-fn select_packages(
-    graph: &DistGraphBuilder,
-    announcing: &PartialAnnouncementTag,
-) -> Vec<(PackageIdx, Vec<String>)> {
-    info!("");
-    info!("selecting packages from workspace: ");
-    // Choose which binaries we want to release
-    let disabled_sty = console::Style::new().dim();
-    let enabled_sty = console::Style::new();
-    let mut rust_releases = vec![];
-    for (pkg_id, pkg) in graph.workspace().packages() {
-        let pkg_name = &pkg.name;
-
-        // Determine if this package's binaries should be Released
-        let disabled_reason = check_dist_package(graph, pkg_id, pkg, announcing);
-
-        // Report our conclusion/discoveries
-        let sty;
-        if let Some(reason) = &disabled_reason {
-            sty = &disabled_sty;
-            info!("  {}", sty.apply_to(format!("{pkg_name} ({reason})")));
-        } else {
-            sty = &enabled_sty;
-            info!("  {}", sty.apply_to(pkg_name));
-        }
-
-        // Report each binary and potentially add it to the Release for this package
-        let mut rust_binaries = vec![];
-        for binary in &pkg.binaries {
-            info!("    {}", sty.apply_to(format!("[bin] {}", binary)));
-            // In the future might want to allow this to be granular for each binary
-            if disabled_reason.is_none() {
-                rust_binaries.push(binary.to_owned());
-            }
-        }
-
-        // If any binaries were accepted for this package, it's a Release!
-        if !rust_binaries.is_empty() {
-            rust_releases.push((pkg_id, rust_binaries));
-        }
-    }
-    info!("");
-
-    rust_releases
-}
-
-/// Get a list of possible version --tags to use, given a list of packages we want to Announce
-///
-/// This is the set of options used by tag inference. Inference succeeds if
-/// there's only one key in the output.
-fn possible_tags<'a>(
-    graph: &'a DistGraphBuilder,
-    rust_releases: impl IntoIterator<Item = PackageIdx>,
-) -> SortedMap<&'a Version, Vec<PackageIdx>> {
-    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();
-        versions.entry(version).or_default().push(pkg_idx);
-    }
-    versions
-}
-
-/// Get a help printout for what --tags could have been passed
-fn tag_help(
-    graph: &DistGraphBuilder,
-    versions: SortedMap<&Version, Vec<PackageIdx>>,
-    base_suggestion: &str,
-) -> String {
-    use std::fmt::Write;
-    let mut help = String::new();
-
-    let Some(some_pkg) = versions
-        .first_key_value()
-        .and_then(|(_, packages)| packages.first())
-    else {
-        return r#"It appears that you have no packages in your workspace with distable binaries. You can rerun with "--verbose=info" to see what cargo-dist thinks is in your workspace. Here are some typical issues:
-
-    If you're trying to use cargo-dist to announce libraries, we require you explicitly select the library with e.g. "--tag=my-library-v1.0.0", as this mode is experimental.
-
-    If you have binaries in your workspace, `publish = false` could be hiding them and adding "dist = true" to [package.metadata.dist] in your Cargo.toml may help."#.to_owned();
-    };
-
-    help.push_str(base_suggestion);
-    help.push_str("\n\n");
-    help.push_str("Here are some options:\n\n");
-    for (version, packages) in &versions {
-        write!(help, "--tag=v{version} will Announce: ").unwrap();
-        let mut multi_package = false;
-        for &pkg_id in packages {
-            let info = &graph.workspace().package(pkg_id);
-            if multi_package {
-                write!(help, ", ").unwrap();
-            } else {
-                multi_package = true;
-            }
-            write!(help, "{}", info.name).unwrap();
-        }
-        writeln!(help).unwrap();
-    }
-    help.push('\n');
-    let info = &graph.workspace().package(*some_pkg);
-    let some_tag = format!(
-        "--tag={}-v{}",
-        info.name,
-        info.version.as_ref().unwrap().cargo()
-    );
-
-    writeln!(
-        help,
-        "you can also request any single package with {some_tag}"
-    )
-    .unwrap();
-
-    help
-}
-
-/// Try to strip-prefix a package name from the given input, preferring whichever one is longest
-/// (to disambiguate situations where you have `my-app` and `my-app-helper`).
-///
-/// If a match is found, then the return value is:
-/// * the idx of the package
-/// * the rest of the input
-fn strip_prefix_package<'a>(
-    input: &'a str,
-    graph: &DistGraphBuilder,
-) -> Option<(PackageIdx, &'a str)> {
-    let mut result: Option<(PackageIdx, &'a str)> = None;
-    for (pkg_id, package) in graph.workspace().packages() {
-        if let Some(rest) = input.strip_prefix(&package.name) {
-            if let Some((_, best)) = result {
-                if best.len() <= rest.len() {
-                    continue;
-                }
-            }
-            result = Some((pkg_id, rest))
-        }
-    }
-    result
-}


Test patch:
diff --git a/cargo-dist/src/tests/tag.rs b/cargo-dist/src/tests/tag.rs
index 46efbc0b..755396a7 100644
--- a/cargo-dist/src/tests/tag.rs
+++ b/cargo-dist/src/tests/tag.rs
@@ -7,7 +7,8 @@
 use super::mock::*;
 use semver::Version;
 
-use crate::{config::ArtifactMode, select_tag, DistGraphBuilder};
+use crate::announce::select_tag;
+use crate::{config::ArtifactMode, DistGraphBuilder};
 
 #[test]
 fn parse_one() {
diff --git a/cargo-dist/tests/snapshots/akaikatana_basic.snap b/cargo-dist/tests/snapshots/akaikatana_basic.snap
index 62664a47..9147e11b 100644
--- a/cargo-dist/tests/snapshots/akaikatana_basic.snap
+++ b/cargo-dist/tests/snapshots/akaikatana_basic.snap
@@ -1296,7 +1296,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/mistydemeo/akaikatana-repack/releases/download/v0.2.0",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/akaikatana_musl.snap b/cargo-dist/tests/snapshots/akaikatana_musl.snap
index 39e4f5b1..c0fe6b25 100644
--- a/cargo-dist/tests/snapshots/akaikatana_musl.snap
+++ b/cargo-dist/tests/snapshots/akaikatana_musl.snap
@@ -980,7 +980,14 @@ download_binary_and_run_installer "$@" || exit 1
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/mistydemeo/akaikatana-repack/releases/download/v0.2.0",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/akaikatana_repo_with_dot_git.snap b/cargo-dist/tests/snapshots/akaikatana_repo_with_dot_git.snap
index 62664a47..9147e11b 100644
--- a/cargo-dist/tests/snapshots/akaikatana_repo_with_dot_git.snap
+++ b/cargo-dist/tests/snapshots/akaikatana_repo_with_dot_git.snap
@@ -1296,7 +1296,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/mistydemeo/akaikatana-repack/releases/download/v0.2.0",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_basic.snap b/cargo-dist/tests/snapshots/axolotlsay_basic.snap
index 04f74afe..5c819b8f 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_basic.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_basic.snap
@@ -2220,7 +2220,14 @@ maybeInstall(true).then(run);
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap b/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
index 4918fffe..74b9754d 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
@@ -2195,7 +2195,14 @@ maybeInstall(true).then(run);
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_musl.snap b/cargo-dist/tests/snapshots/axolotlsay_musl.snap
index 721b8cbb..ea6cb675 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_musl.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_musl.snap
@@ -1883,7 +1883,14 @@ maybeInstall(true).then(run);
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_musl_no_gnu.snap b/cargo-dist/tests/snapshots/axolotlsay_musl_no_gnu.snap
index e3df3343..e24c6cc5 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_musl_no_gnu.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_musl_no_gnu.snap
@@ -1829,7 +1829,14 @@ maybeInstall(true).then(run);
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap b/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
index 825b4548..c80bab9c 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
@@ -2195,7 +2195,14 @@ maybeInstall(true).then(run);
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign.snap b/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign.snap
index c761f036..19d8e75a 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign.snap
@@ -1288,7 +1288,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign_prod.snap b/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign_prod.snap
index 5f837a55..c553954c 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign_prod.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_ssldotcom_windows_sign_prod.snap
@@ -1288,7 +1288,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/axolotlsay_user_publish_job.snap b/cargo-dist/tests/snapshots/axolotlsay_user_publish_job.snap
index ed702ed0..a204f752 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_user_publish_job.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_user_publish_job.snap
@@ -2195,7 +2195,14 @@ maybeInstall(true).then(run);
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 ================ github-ci.yml ================
diff --git a/cargo-dist/tests/snapshots/install_path_cargo_home.snap b/cargo-dist/tests/snapshots/install_path_cargo_home.snap
index d812b7c3..d17dce7f 100644
--- a/cargo-dist/tests/snapshots/install_path_cargo_home.snap
+++ b/cargo-dist/tests/snapshots/install_path_cargo_home.snap
@@ -1298,7 +1298,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_env_no_subdir.snap b/cargo-dist/tests/snapshots/install_path_env_no_subdir.snap
index b25a1cfa..d9446642 100644
--- a/cargo-dist/tests/snapshots/install_path_env_no_subdir.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_no_subdir.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_env_subdir.snap b/cargo-dist/tests/snapshots/install_path_env_subdir.snap
index 40433bdd..808bd90c 100644
--- a/cargo-dist/tests/snapshots/install_path_env_subdir.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_subdir.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_env_subdir_space.snap b/cargo-dist/tests/snapshots/install_path_env_subdir_space.snap
index 4664b5ad..20e50453 100644
--- a/cargo-dist/tests/snapshots/install_path_env_subdir_space.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_subdir_space.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_env_subdir_space_deeper.snap b/cargo-dist/tests/snapshots/install_path_env_subdir_space_deeper.snap
index df1e2485..876cfadd 100644
--- a/cargo-dist/tests/snapshots/install_path_env_subdir_space_deeper.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_subdir_space_deeper.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_home_subdir_deeper.snap b/cargo-dist/tests/snapshots/install_path_home_subdir_deeper.snap
index be031305..7d4558d1 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_deeper.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_deeper.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_home_subdir_min.snap b/cargo-dist/tests/snapshots/install_path_home_subdir_min.snap
index 2c3c9839..439d1559 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_min.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_min.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_home_subdir_space.snap b/cargo-dist/tests/snapshots/install_path_home_subdir_space.snap
index 4e86cb91..de30dbf9 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_space.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_space.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/install_path_home_subdir_space_deeper.snap b/cargo-dist/tests/snapshots/install_path_home_subdir_space_deeper.snap
index eb74406a..3bed7a27 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_space_deeper.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_space_deeper.snap
@@ -1278,7 +1278,14 @@ Install-Binary "$Args"
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/axolotlsay/releases/download/v0.2.1",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 
diff --git a/cargo-dist/tests/snapshots/lib_manifest.snap b/cargo-dist/tests/snapshots/lib_manifest.snap
index 7c6a654b..f58c8215 100644
--- a/cargo-dist/tests/snapshots/lib_manifest.snap
+++ b/cargo-dist/tests/snapshots/lib_manifest.snap
@@ -20,7 +20,14 @@ stdout:
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/cargo-dist/releases/download/cargo-dist-schema-v1.0.0-FAKEVERSION",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 stderr:
diff --git a/cargo-dist/tests/snapshots/lib_manifest_slash.snap b/cargo-dist/tests/snapshots/lib_manifest_slash.snap
index 7c6a654b..1e9e9158 100644
--- a/cargo-dist/tests/snapshots/lib_manifest_slash.snap
+++ b/cargo-dist/tests/snapshots/lib_manifest_slash.snap
@@ -20,7 +20,14 @@ stdout:
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/cargo-dist/releases/download/cargo-dist-schema/v1.0.0-FAKEVERSION",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 stderr:
diff --git a/cargo-dist/tests/snapshots/manifest.snap b/cargo-dist/tests/snapshots/manifest.snap
index b6e82f99..f14fc6a6 100644
--- a/cargo-dist/tests/snapshots/manifest.snap
+++ b/cargo-dist/tests/snapshots/manifest.snap
@@ -329,7 +329,14 @@ stdout:
       "pr_run_mode": "plan"
     }
   },
-  "linkage": []
+  "linkage": [],
+  "hosting": {
+    "staging_artifacts_url": null,
+    "live_artifacts_url": "https://github.com/axodotdev/cargo-dist/releases/download/v1.0.0-FAKEVERSION",
+    "upload_url": null,
+    "publish_url": null,
+    "announce_url": null
+  }
 }
 
 stderr: