Repository: axodotdev/cargo-dist
Pull request #39 (2023-01-12 16:31:59)

progress on installer
need to merge this to do some live runs on ci, will definitely break things for a bit


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d8d9c9c8..c337e855 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,7 +30,8 @@ on:
       - v[0-9]+.*
 
 env:
-  ALL_CARGO_DIST_TARGET_ARGS: --target=x86_64-unknown-linux-gnu --target=x86_64-apple-darwin --target=x86_64-pc-windows-msvc 
+  ALL_CARGO_DIST_TARGET_ARGS: --target=x86_64-unknown-linux-musl --target=x86_64-apple-darwin --target=x86_64-pc-windows-msvc 
+  ALL_CARGO_DIST_INSTALLER_ARGS: --installer=github-shell 
 
 jobs:
   # Create the Github Release™️ so the packages have something to be uploaded to
@@ -57,12 +58,12 @@ jobs:
       matrix:
         # For these target platforms
         include:
-        - target: x86_64-unknown-linux-gnu
-          os: ubuntu-latest
+        - target: x86_64-unknown-linux-musl
+          os: ubuntu-20.04
         - target: x86_64-apple-darwin
-          os: macos-latest
+          os: macos-11
         - target: x86_64-pc-windows-msvc
-          os: windows-latest
+          os: windows-2019
 
     runs-on: ${{ matrix.os }}
     env:
@@ -72,7 +73,7 @@ jobs:
       - name: Install Rust
         run: rustup update stable && rustup default stable
       - name: Install cargo-dist
-        # Currently we install cargo-dist from the local repo, in the future when it's
+        # Currently we install cargo-dist from path, in the future when it's
         # published on crates.io or has prebuilt binaries, we'll do better.
         run: cargo install --path ./cargo-dist
       - name: Run cargo-dist
@@ -81,7 +82,7 @@ jobs:
         # The two platforms don't agree on how to talk about env vars but they
         # do agree on 'cat' and '$()' so we use that to marshal values between commmands.
         run: |
-          cargo dist --output-format=json > dist-manifest.json
+          cargo dist --target=${{ matrix.target }} --output-format=json > dist-manifest.json
           echo "dist ran successfully"
           cat dist-manifest.json
           cat dist-manifest.json | jq --raw-output ".releases[].artifacts[].path" > uploads.txt
@@ -101,17 +102,26 @@ jobs:
       - name: Install Rust
         run: rustup update stable && rustup default stable
       - name: Install cargo-dist
-        # Currently we install cargo-dist from the local repo, in the future when it's
+        # Currently we install cargo-dist from path, in the future when it's
         # published on crates.io or has prebuilt binaries, we'll do better.
         run: cargo install --path ./cargo-dist
-      - name: Run cargo-dist
+      - name: Run cargo-dist manifest
         run: |
-          cargo dist manifest --output-format=json $ALL_CARGO_DIST_TARGET_ARGS > dist-manifest.json
-          echo "dist ran successfully"
+          cargo dist manifest --output-format=json $ALL_CARGO_DIST_TARGET_ARGS $ALL_CARGO_DIST_INSTALLER_ARGS > dist-manifest.json
+          echo "dist manifest ran successfully"
           cat dist-manifest.json
           gh release upload ${{ needs.create-release.outputs.tag }} dist-manifest.json
-          echo "uploaded!"
-
+          echo "uploaded manifest!"
+      - name: Run cargo-dist --installer=...
+        run: |
+          cargo dist --output-format=json --no-build $ALL_CARGO_DIST_INSTALLER_ARGS > dist-manifest.json
+          echo "dist ran successfully"
+          cat dist-manifest.json
+          cat dist-manifest.json | jq --raw-output '.releases[].artifacts[] | select(.kind == "installer") | .path' > uploads.txt
+          echo "uploading..."
+          cat uploads.txt
+          gh release upload ${{ needs.create-release.outputs.tag }} $(cat uploads.txt)
+          echo "uploaded installers!"
 
   # Mark the Github Release™️ as a non-draft now that everything has succeeded!
   publish-release:
diff --git a/cargo-dist-schema/src/lib.rs b/cargo-dist-schema/src/lib.rs
index 8bd9d0fd..e631a893 100644
--- a/cargo-dist-schema/src/lib.rs
+++ b/cargo-dist-schema/src/lib.rs
@@ -82,6 +82,9 @@ pub enum ArtifactKind {
     /// Machine-readable metadata
     #[serde(rename = "dist-metadata")]
     DistMetadata,
+    /// Installer
+    #[serde(rename = "installer")]
+    Installer,
 }
 
 /// An executable artifact (exe/binary)
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 a9478c9c..3c6ec102 100644
--- a/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
+++ b/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
@@ -68,6 +68,21 @@ expression: "serde_json::to_string_pretty(&schema).unwrap()"
               ]
             }
           }
+        },
+        {
+          "description": "Installer",
+          "type": "object",
+          "required": [
+            "kind"
+          ],
+          "properties": {
+            "kind": {
+              "type": "string",
+              "enum": [
+                "installer"
+              ]
+            }
+          }
         }
       ],
       "required": [
diff --git a/cargo-dist/src/ci.rs b/cargo-dist/src/ci.rs
index ed0f4ede..29530fa2 100644
--- a/cargo-dist/src/ci.rs
+++ b/cargo-dist/src/ci.rs
@@ -4,7 +4,9 @@ use camino::Utf8PathBuf;
 use miette::{IntoDiagnostic, WrapErr};
 use tracing::warn;
 
-const GITHUB_CI_PART1: &str = r###"
+use crate::InstallerStyle;
+
+const GITHUB_CI_TRIGGER: &str = r###"
 # CI that:
 #
 # * checks for a Git Tag that looks like a release ("v1.2.0")
@@ -37,7 +39,7 @@ on:
 
 env:"###;
 
-const GITHUB_CI_PART2: &str = r###"
+const GITHUB_CI_CREATE_RELEASE: &str = r###"
 jobs:
   # Create the Github Release™️ so the packages have something to be uploaded to
   create-release:
@@ -64,7 +66,7 @@ jobs:
         # For these target platforms
         include:"###;
 
-const GITHUB_CI_PART3: &str = r###"
+const GITHUB_CI_ARTIFACT_TASKS: &str = r###"
     runs-on: ${{ matrix.os }}
     env:
       GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -82,7 +84,7 @@ const GITHUB_CI_PART3: &str = r###"
         # The two platforms don't agree on how to talk about env vars but they
         # do agree on 'cat' and '$()' so we use that to marshal values between commmands.
         run: |
-          cargo dist --output-format=json > dist-manifest.json
+          cargo dist --target=${{ matrix.target }} --output-format=json > dist-manifest.json
           echo "dist ran successfully"
           cat dist-manifest.json
           cat dist-manifest.json | jq --raw-output ".releases[].artifacts[].path" > uploads.txt
@@ -105,15 +107,26 @@ const GITHUB_CI_PART3: &str = r###"
         # Currently we install cargo-dist from git, in the future when it's
         # published on crates.io or has prebuilt binaries, we'll do better.
         run: cargo install --git https://github.com/axodotdev/cargo-dist/
-      - name: Run cargo-dist
+      - name: Run cargo-dist manifest
         run: |
-          cargo dist manifest --output-format=json $ALL_CARGO_DIST_TARGET_ARGS > dist-manifest.json
-          echo "dist ran successfully"
+          cargo dist manifest --output-format=json $ALL_CARGO_DIST_TARGET_ARGS $ALL_CARGO_DIST_INSTALLER_ARGS > dist-manifest.json
+          echo "dist manifest ran successfully"
           cat dist-manifest.json
           gh release upload ${{ needs.create-release.outputs.tag }} dist-manifest.json
-          echo "uploaded!"
+          echo "uploaded manifest!""###;
 
+const GITHUB_CI_INSTALLERS: &str = r###"      - name: Run cargo-dist --installer=...
+        run: |
+          cargo dist --output-format=json --no-build $ALL_CARGO_DIST_INSTALLER_ARGS > dist-manifest.json
+          echo "dist ran successfully"
+          cat dist-manifest.json
+          cat dist-manifest.json | jq --raw-output '.releases[].artifacts[] | select(.kind == "installer") | .path' > uploads.txt
+          echo "uploading..."
+          cat uploads.txt
+          gh release upload ${{ needs.create-release.outputs.tag }} $(cat uploads.txt)
+          echo "uploaded installers!""###;
 
+const GITHUB_CI_FINISH_RELEASE: &str = r###"
   # Mark the Github Release™️ as a non-draft now that everything has succeeded!
   publish-release:
     needs: [create-release, upload-artifacts, upload-manifest]
@@ -130,6 +143,7 @@ const GITHUB_CI_PART3: &str = r###"
 pub fn generate_github_ci(
     workspace_dir: &Utf8PathBuf,
     targets: &[String],
+    installers: &[InstallerStyle],
 ) -> Result<(), miette::Report> {
     const GITHUB_CI_DIR: &str = ".github/workflows/";
     const GITHUB_CI_FILE: &str = "release.yml";
@@ -143,29 +157,42 @@ pub fn generate_github_ci(
     let mut file = File::create(ci_file)
         .into_diagnostic()
         .wrap_err("Failed to create ci file")?;
-    write_github_ci(&mut file, targets)
+    write_github_ci(&mut file, targets, installers)
         .into_diagnostic()
         .wrap_err("Failed to write to CI file")?;
     Ok(())
 }
 
-fn write_github_ci(f: &mut File, targets: &[String]) -> Result<(), std::io::Error> {
+fn write_github_ci(
+    f: &mut File,
+    targets: &[String],
+    installers: &[InstallerStyle],
+) -> Result<(), std::io::Error> {
     use std::io::Write;
 
-    writeln!(f, "{GITHUB_CI_PART1}")?;
+    writeln!(f, "{GITHUB_CI_TRIGGER}")?;
 
     // Write out target args
     let mut target_args = Vec::new();
     for target in targets {
         write!(&mut target_args, "--target={target} ")?;
     }
-    writeln!(
-        f,
-        "  ALL_CARGO_DIST_TARGET_ARGS: {}",
-        String::from_utf8(target_args).unwrap()
-    )?;
+    let target_args = String::from_utf8(target_args).unwrap();
+    writeln!(f, "  ALL_CARGO_DIST_TARGET_ARGS: {target_args}")?;
+
+    // Write out the installer args
+    let mut installer_args = Vec::new();
+    for installer in installers {
+        let installer = match installer {
+            InstallerStyle::GithubShell => "github-shell",
+            InstallerStyle::GithubPowershell => "github-powershell",
+        };
+        write!(&mut installer_args, "--installer={installer} ")?;
+    }
+    let installer_args = String::from_utf8(installer_args).unwrap();
+    writeln!(f, "  ALL_CARGO_DIST_INSTALLER_ARGS: {installer_args}")?;
 
-    writeln!(f, "{GITHUB_CI_PART2}")?;
+    writeln!(f, "{GITHUB_CI_CREATE_RELEASE}")?;
 
     for target in targets {
         let Some(os) = github_os_for_target(target) else {
@@ -176,18 +203,26 @@ fn write_github_ci(f: &mut File, targets: &[String]) -> Result<(), std::io::Erro
         writeln!(f, "          os: {os}")?;
     }
 
-    writeln!(f, "{GITHUB_CI_PART3}")?;
+    writeln!(f, "{GITHUB_CI_ARTIFACT_TASKS}")?;
+
+    if !installers.is_empty() {
+        writeln!(f, "{GITHUB_CI_INSTALLERS}")?;
+    }
 
+    writeln!(f, "{GITHUB_CI_FINISH_RELEASE}")?;
     Ok(())
 }
 
 fn github_os_for_target(target: &str) -> Option<&'static str> {
+    // We want to default to older runners to minimize the places
+    // where random system dependencies can creep in and be very
+    // recent. This helps with portability!
     if target.contains("linux") {
-        Some("ubuntu-latest")
+        Some("ubuntu-20.04")
     } else if target.contains("apple") {
-        Some("macos-latest")
+        Some("macos-11")
     } else if target.contains("windows") {
-        Some("windows-latest")
+        Some("windows-2019")
     } else {
         None
     }
diff --git a/cargo-dist/src/cli.rs b/cargo-dist/src/cli.rs
index 5daec1dc..9cf9aaff 100644
--- a/cargo-dist/src/cli.rs
+++ b/cargo-dist/src/cli.rs
@@ -41,11 +41,21 @@ pub struct Cli {
     #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
     pub output_format: OutputFormat,
 
+    /// Strip local paths from output (e.g. in the dist manifest json)
+    #[clap(long)]
+    #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
+    pub no_local_paths: bool,
+
     /// Target triples we want to build
     #[clap(long)]
     #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
     pub target: Vec<String>,
 
+    /// Installers we want to build
+    #[clap(long)]
+    #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
+    pub installer: Vec<InstallerStyle>,
+
     // Add the args from the "real" build command
     #[clap(flatten)]
     pub build_args: BuildArgs,
@@ -75,7 +85,11 @@ pub enum Commands {
 }
 
 #[derive(Args)]
-pub struct BuildArgs {}
+pub struct BuildArgs {
+    /// Don't actually do any builds, this can be useful for generating only installers
+    #[clap(long)]
+    pub no_builds: bool,
+}
 
 #[derive(Args)]
 pub struct InitArgs {
@@ -106,6 +120,25 @@ impl CiStyle {
     }
 }
 
+/// A style of installer to generate
+#[derive(ValueEnum, Clone, Copy)]
+pub enum InstallerStyle {
+    /// Generates a shell script that fetches from github ci
+    GithubShell,
+    /// Generates a powershell script that fetches from github ci
+    GithubPowershell,
+}
+
+impl InstallerStyle {
+    /// Convert the application version of this enum to the library version
+    pub fn to_lib(self) -> cargo_dist::InstallerStyle {
+        match self {
+            InstallerStyle::GithubShell => cargo_dist::InstallerStyle::GithubShell,
+            InstallerStyle::GithubPowershell => cargo_dist::InstallerStyle::GithubPowershell,
+        }
+    }
+}
+
 #[derive(Args)]
 pub struct ManifestArgs {}
 
diff --git a/cargo-dist/src/installer.rs b/cargo-dist/src/installer.rs
new file mode 100644
index 00000000..c124a9cd
--- /dev/null
+++ b/cargo-dist/src/installer.rs
@@ -0,0 +1,284 @@
+use std::fs::File;
+
+use miette::{Context, IntoDiagnostic};
+
+use crate::{ArtifactTarget, InstallerInfo};
+
+const GITHUB_INSTALL_SH_SCRIPT_PART1: &str = r###" 
+#!/bin/bash
+#
+# Licensed under the MIT license
+# <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
+# option. This file may not be copied, modified, or distributed
+# except according to those terms.
+
+# This is just a little script that can be downloaded from the internet to
+# install $APP_NAME. It downloads the tarball from GitHub releases,
+# extracts it and runs `???TODO???`. This means that you can pass
+# arguments to this shell script and they will be passed along to the installer.
+
+set -u
+"###;
+
+const GITHUB_INSTALL_SH_SCRIPT_PART2: &str = r###"
+download_binary_and_run_installer() {
+    downloader --check
+    need_cmd mktemp
+    need_cmd chmod
+    need_cmd mkdir
+    need_cmd rm
+    need_cmd rmdir
+    need_cmd tar
+    need_cmd which
+    need_cmd dirname
+    need_cmd awk
+    need_cmd cut
+
+    # if $VERSION isn't provided or has 0 length, use version from cargo.toml
+    # ${VERSION:-} checks if version exists, and if doesn't uses the default
+    # which is after the :-, which in this case is empty. -z checks for empty str
+    if [ -z ${VERSION:-} ]; then
+        # VERSION is either not set or empty
+        DOWNLOAD_VERSION=$PACKAGE_VERSION
+    else
+        # VERSION set and not empty
+        DOWNLOAD_VERSION=$VERSION
+    fi
+
+
+    get_architecture || return 1
+    local _arch="$RETVAL"
+    assert_nz "$_arch" "arch"
+
+    local _ext=""
+    local _zip_ext=".tar.xz"
+    case "$_arch" in
+        *windows*)
+            _ext=".exe"
+            _zip_ext=".zip"
+            ;;
+    esac
+
+    local _tardir="$APP_NAME-$DOWNLOAD_VERSION-${_arch}"
+    local _url="$BINARY_DOWNLOAD_PREFIX/$DOWNLOAD_VERSION/${_tardir}${_zip_ext}"
+    local _dir="$(mktemp -d 2>/dev/null || ensure mktemp -d -t $APP_NAME)"
+    local _file="$_dir/input.tar.gz"
+    local _bin="$_dir/$APP_NAME$_ext"
+
+    say "downloading $APP_NAME from $_url" 1>&2
+
+    ensure mkdir -p "$_dir"
+    downloader "$_url" "$_file"
+    if [ $? != 0 ]; then
+      say "failed to download $_url"
+      say "this may be a standard network error, but it may also indicate"
+      say "that $APP_NAME's release process is not working. When in doubt"
+      say "please feel free to open an issue!"
+      say "$REPO/issues/new/choose"
+      exit 1
+    fi
+
+    ensure tar xf "$_file" --strip-components 1 -C "$_dir"
+
+    install "$_bin" "$@"
+
+    local _retval=$?
+
+    ignore rm -rf "$_dir"
+
+    return "$_retval"
+}
+
+install() {
+    # TODO: REWORK THE INSTALL STEP, THIS IS A JANK HACK
+
+    local _cargo_home_bin=~/.cargo/bin/
+    local _bin="$1"
+    mkdir -p "$_cargo_home_bin"
+    cp "$_bin" "$_cargo_home_bin"
+}
+
+get_architecture() {
+    local _ostype="$(uname -s)"
+    local _cputype="$(uname -m)"
+
+    if [ "$_ostype" = Darwin -a "$_cputype" = i386 ]; then
+        # Darwin `uname -s` lies
+        if sysctl hw.optional.x86_64 | grep -q ': 1'; then
+            local _cputype=x86_64
+        fi
+    fi
+
+    if [ "$_ostype" = Darwin -a "$_cputype" = arm64 ]; then
+        # Darwin `uname -s` doesn't seem to lie on Big Sur
+        # but the cputype we want is called aarch64, not arm64 (they are equivalent)
+        local _cputype=aarch64
+    fi
+
+    case "$_ostype" in
+        Linux)
+            if has_required_glibc; then
+                local _ostype=unknown-linux-gnu
+            else
+                local _ostype=unknown-linux-musl
+                say "Downloading musl binary that does not include \`rover supergraph compose\`."
+            fi
+            ;;
+
+        Darwin)
+            local _ostype=apple-darwin
+            ;;
+
+        MINGW* | MSYS* | CYGWIN*)
+            local _ostype=pc-windows-msvc
+            ;;
+
+        *)
+            err "no precompiled binaries available for OS: $_ostype"
+            ;;
+    esac
+
+    case "$_cputype" in
+        # these are the only two acceptable values for cputype
+        x86_64 | aarch64 )
+            ;;
+        *)
+            err "no precompiled binaries available for CPU architecture: $_cputype"
+
+    esac
+
+    local _arch="$_cputype-$_ostype"
+
+    RETVAL="$_arch"
+}
+
+say() {
+    local green=`tput setaf 2 2>/dev/null || echo ''`
+    local reset=`tput sgr0 2>/dev/null || echo ''`
+    echo "$1"
+}
+
+err() {
+    local red=`tput setaf 1 2>/dev/null || echo ''`
+    local reset=`tput sgr0 2>/dev/null || echo ''`
+    say "${red}ERROR${reset}: $1" >&2
+    exit 1
+}
+
+has_required_glibc() {
+    local _ldd_version="$(ldd --version 2>&1 | head -n1)"
+    # glibc version string is inconsistent across distributions
+    # instead check if the string does not contain musl (case insensitive)
+    if echo "${_ldd_version}" | grep -iv musl >/dev/null; then
+        local _glibc_version=$(echo "${_ldd_version}" | awk 'NR==1 { print $NF }')
+        local _glibc_major_version=$(echo "${_glibc_version}" | cut -d. -f1)
+        local _glibc_min_version=$(echo "${_glibc_version}" | cut -d. -f2)
+        local _min_major_version=2
+        local _min_minor_version=17
+        if [ "${_glibc_major_version}" -gt "${_min_major_version}" ] \
+            || { [ "${_glibc_major_version}" -eq "${_min_major_version}" ] \
+            && [ "${_glibc_min_version}" -ge "${_min_minor_version}" ]; }; then
+            return 0
+        else
+            say "This operating system needs glibc >= ${_min_major_version}.${_min_minor_version}, but only has ${_libc_version} installed."
+        fi
+    else
+        say "This operating system does not support dynamic linking to glibc."
+    fi
+
+    return 1
+}
+
+need_cmd() {
+    if ! check_cmd "$1"
+    then err "need '$1' (command not found)"
+    fi
+}
+
+check_cmd() {
+    command -v "$1" > /dev/null 2>&1
+    return $?
+}
+
+need_ok() {
+    if [ $? != 0 ]; then err "$1"; fi
+}
+
+assert_nz() {
+    if [ -z "$1" ]; then err "assert_nz $2"; fi
+}
+
+# Run a command that should never fail. If the command fails execution
+# will immediately terminate with an error showing the failing
+# command.
+ensure() {
+    "$@"
+    need_ok "command failed: $*"
+}
+
+# This is just for indicating that commands' results are being
+# intentionally ignored. Usually, because it's being executed
+# as part of error handling.
+ignore() {
+    "$@"
+}
+
+# This wraps curl or wget. Try curl first, if not installed,
+# use wget instead.
+downloader() {
+    if check_cmd curl
+    then _dld=curl
+    elif check_cmd wget
+    then _dld=wget
+    else _dld='curl or wget' # to be used in error message of need_cmd
+    fi
+
+    if [ "$1" = --check ]
+    then need_cmd "$_dld"
+    elif [ "$_dld" = curl ]
+    then curl -sSfL "$1" -o "$2"
+    elif [ "$_dld" = wget ]
+    then wget "$1" -O "$2"
+    else err "Unknown downloader"   # should not reach here
+    fi
+}
+
+download_binary_and_run_installer "$@" || exit 1
+"###;
+
+pub(crate) fn generate_github_install_sh_script(
+    artifact: &ArtifactTarget,
+    info: &InstallerInfo,
+) -> Result<(), miette::Report> {
+    let installer_file = &artifact.file_path;
+    let mut file = File::create(installer_file)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("Failed to create installer file {installer_file}"))?;
+    write_github_install_sh_script(&mut file, info)
+        .into_diagnostic()
+        .wrap_err("Failed to write to installer file")?;
+    Ok(())
+}
+
+fn write_github_install_sh_script(
+    f: &mut File,
+    info: &InstallerInfo,
+) -> Result<(), std::io::Error> {
+    use std::io::Write;
+    let InstallerInfo {
+        app_name,
+        app_version,
+        repo_url,
+    } = info;
+
+    writeln!(f, "{GITHUB_INSTALL_SH_SCRIPT_PART1}")?;
+
+    writeln!(f, "REPO=\"{repo_url}\"")?;
+    writeln!(f, "APP_NAME=\"{app_name}\"")?;
+    writeln!(f, "BINARY_DOWNLOAD_PREFIX=\"$REPO/releases/download\"")?;
+    writeln!(f, "PACKAGE_VERSION=\"{app_version}\"")?;
+
+    writeln!(f, "{GITHUB_INSTALL_SH_SCRIPT_PART2}")?;
+
+    Ok(())
+}
diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs
index e9cd3bfe..213537e6 100644
--- a/cargo-dist/src/lib.rs
+++ b/cargo-dist/src/lib.rs
@@ -34,6 +34,7 @@ use miette::{miette, Context, IntoDiagnostic};
 
 pub mod ci;
 pub mod errors;
+pub mod installer;
 #[cfg(test)]
 mod tests;
 
@@ -71,8 +72,14 @@ pub struct DistMetadata {}
 /// Global config for commands
 #[derive(Debug)]
 pub struct Config {
+    /// Whether we'll actually run builds (if false we'll still generate installers)
+    pub build: bool,
+    /// Whether local paths to files should be in the final dist json output
+    pub no_local_paths: bool,
     /// Target triples we want to build for
     pub targets: Vec<String>,
+    /// Installers we want to generate
+    pub installers: Vec<InstallerStyle>,
 }
 
 #[derive(Clone, Copy, Debug)]
@@ -80,6 +87,12 @@ pub enum CiStyle {
     Github,
 }
 
+#[derive(Clone, Copy, Debug)]
+pub enum InstallerStyle {
+    GithubShell,
+    GithubPowershell,
+}
+
 /// A unique id for a [`BuildTarget`][]
 #[derive(Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
 struct BuildTargetIdx(usize);
@@ -194,28 +207,30 @@ struct SymbolsBuiltAsset {
 
 /// A distributable artifact we want to build
 #[derive(Debug)]
-struct ArtifactTarget {
+pub(crate) struct ArtifactTarget {
     /// The target platform
     ///
     /// i.e. `x86_64-pc-windows-msvc`
-    target_triple: String,
-    /// The full name of the artifact
+    target_triple: Option<String>,
+    /// The name of the directory this artifact's contents will be stored in (if necessary).
+    ///
+    /// This directory is technically a transient thing but it will show up as the name of
+    /// the directory in a `tar`. Single file artifacts don't need this.
     ///
     /// i.e. `cargo-dist-v0.1.0-x86_64-pc-windows-msvc`
-    full_name: String,
-    /// The path to the directory where this artifact's
-    /// contents will be gathered before bundling.
+    dir_name: Option<String>,
+    /// The path of the directory this artifact's contents will be stored in (if necessary).
     ///
     /// i.e. `/.../target/dist/cargo-dist-v0.1.0-x86_64-pc-windows-msvc/`
-    dir_path: Utf8PathBuf,
-    /// The file name of the artifact
+    dir_path: Option<Utf8PathBuf>,
+    /// The file name of the artifact when it's completed and placed in the root of the dist dir.
     ///
     /// i.e. `cargo-dist-v0.1.0-x86_64-pc-windows-msvc.zip`
-    file_name: String,
-    /// The path where the final artifact will appear
+    pub(crate) file_name: String,
+    /// The path where the final artifact will appear in the dist dir.
     ///
     /// i.e. `/.../target/dist/cargo-dist-v0.1.0-x86_64-pc-windows-msvc.zip`
-    file_path: Utf8PathBuf,
+    pub(crate) file_path: Utf8PathBuf,
     /// The bundling method (zip, tar.gz, ...)
     bundle: BundleStyle,
     /// The built assets this artifact will contain
@@ -250,6 +265,7 @@ enum BundleStyle {
     Zip,
     /// `.tar.<compression>`
     Tar(CompressionImpl),
+    Installer(InstallerImpl),
     // TODO: Microsoft MSI installer
     // TODO: Apple .dmg "installer"
     // TODO: flatpak?
@@ -268,6 +284,19 @@ enum CompressionImpl {
     Zstd,
 }
 
+#[derive(Debug, Clone)]
+enum InstallerImpl {
+    GithubShell(InstallerInfo),
+    GithubPowershell(InstallerInfo),
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct InstallerInfo {
+    pub(crate) app_name: String,
+    pub(crate) app_version: String,
+    pub(crate) repo_url: String,
+}
+
 /// Cargo features a [`CargoBuildTarget`][] should use.
 #[derive(Debug)]
 struct CargoTargetFeatures {
@@ -315,14 +344,16 @@ pub fn do_dist(cfg: &Config) -> Result<DistManifest> {
     }
 
     let mut built_assets = HashMap::new();
-    // Run all the builds
-    for target in &dist.targets {
-        let new_built_assets = build_target(&dist, target)?;
-        // Copy the artifacts as soon as possible, future builds may clobber them!
-        for (&built_asset_idx, built_asset) in &new_built_assets {
-            populate_artifact_dirs_with_built_assets(&dist, built_asset_idx, built_asset)?;
+    if cfg.build {
+        // Run all the builds
+        for target in &dist.targets {
+            let new_built_assets = build_target(&dist, target)?;
+            // Copy the artifacts as soon as possible, future builds may clobber them!
+            for (&built_asset_idx, built_asset) in &new_built_assets {
+                populate_artifact_dirs_with_built_assets(&dist, built_asset_idx, built_asset)?;
+            }
+            built_assets.extend(new_built_assets);
         }
-        built_assets.extend(new_built_assets);
     }
 
     // Build all the bundles
@@ -332,13 +363,13 @@ pub fn do_dist(cfg: &Config) -> Result<DistManifest> {
         eprintln!("bundled {}", artifact.file_path);
     }
 
-    Ok(build_manifest(&dist))
+    Ok(build_manifest(cfg, &dist))
 }
 
 /// Just generate the manifest produced by `cargo dist build` without building
 pub fn do_manifest(cfg: &Config) -> Result<DistManifest> {
     let dist = gather_work(cfg)?;
-    Ok(build_manifest(&dist))
+    Ok(build_manifest(cfg, &dist))
 }
 
 /// Precompute all the work this invocation will need to do
@@ -424,10 +455,11 @@ fn gather_work(cfg: &Config) -> Result<DistGraph> {
     // Give each binary its own artifact (for now)
     let mut artifacts = vec![];
     let mut built_assets = vec![];
-    let mut releases = HashMap::<(String, Version), ReleaseTarget>::new();
+    let mut releases = HashMap::<(String, Version), (PackageId, ReleaseTarget)>::new();
     for exe in executables {
         // TODO: make app name configurable? Use some other fields in the PackageMetadata?
         let app_name = exe.exe_name.clone();
+        let package_id = exe.package_id.clone();
         // TODO: allow apps to be versioned separately from packages?
         let version = pkg_graph
             .metadata(&exe.package_id)
@@ -466,26 +498,32 @@ fn gather_work(cfg: &Config) -> Result<DistGraph> {
             .collect();
 
         // TODO: make the bundle name configurable?
-        let exe_full_name = format!("{app_name}-v{version}-{target_triple}");
-        let exe_dir_path = dist_dir.join(&exe_full_name);
+        let exe_dir_name = format!("{app_name}-v{version}-{target_triple}");
+        let exe_dir_path = dist_dir.join(&exe_dir_name);
         let exe_file_ext = match exe_bundle {
             BundleStyle::UncompressedFile => platform_exe_ext,
             BundleStyle::Zip => ".zip",
             BundleStyle::Tar(CompressionImpl::Gzip) => ".tar.gz",
             BundleStyle::Tar(CompressionImpl::Zstd) => ".tar.zstd",
             BundleStyle::Tar(CompressionImpl::Xzip) => ".tar.xz",
+            BundleStyle::Installer(_) => unreachable!("exe's shouldn't be installers"),
         };
-        let exe_bundle_name = format!("{exe_full_name}{exe_file_ext}");
+        let exe_bundle_name = format!("{exe_dir_name}{exe_file_ext}");
         let exe_bundle_path = dist_dir.join(&exe_bundle_name);
         let exe_file_name = format!("{app_name}{platform_exe_ext}");
 
         // Ensure the release exists
-        let release = releases
+        let (_, release) = releases
             .entry((app_name.clone(), version.clone()))
-            .or_insert_with(|| ReleaseTarget {
-                app_name,
-                version,
-                artifacts: vec![],
+            .or_insert_with(|| {
+                (
+                    package_id,
+                    ReleaseTarget {
+                        app_name: app_name.clone(),
+                        version: version.clone(),
+                        artifacts: vec![],
+                    },
+                )
             });
 
         // Tell the target about this BuiltAsset is needs to make
@@ -514,16 +552,14 @@ fn gather_work(cfg: &Config) -> Result<DistGraph> {
 
                 // Create a dedicated artifact for this asset
                 let sym_ext = symbol_kind.ext();
-                let sym_full_name = format!("{exe_full_name}.{sym_ext}");
-                let sym_dir_path = dist_dir.join("syms");
-                let sym_file_name = sym_full_name.clone();
-                let sym_file_path = dist_dir.join(&sym_full_name);
+                let sym_file_name = format!("{exe_dir_name}.{sym_ext}");
+                let sym_file_path = dist_dir.join(&sym_file_name);
 
                 let sym_artifact_idx = ArtifactTargetIdx(artifacts.len());
                 artifacts.push(ArtifactTarget {
-                    target_triple: target_triple.clone(),
-                    full_name: sym_full_name,
-                    dir_path: sym_dir_path,
+                    target_triple: Some(target_triple.clone()),
+                    dir_name: None,
+                    dir_path: None,
                     file_name: sym_file_name,
                     file_path: sym_file_path,
                     bundle: BundleStyle::UncompressedFile,
@@ -544,11 +580,11 @@ fn gather_work(cfg: &Config) -> Result<DistGraph> {
 
         let exe_artifact_idx = ArtifactTargetIdx(artifacts.len());
         artifacts.push(ArtifactTarget {
-            target_triple,
-            full_name: exe_full_name,
+            target_triple: Some(target_triple),
+            dir_name: Some(exe_dir_name),
+            dir_path: Some(exe_dir_path),
             file_path: exe_bundle_path,
             file_name: exe_bundle_name.clone(),
-            dir_path: exe_dir_path,
             bundle: exe_bundle,
             built_assets: Some((exe_asset_idx, Utf8PathBuf::from(exe_file_name)))
                 .into_iter()
@@ -559,7 +595,55 @@ fn gather_work(cfg: &Config) -> Result<DistGraph> {
         release.artifacts.push(exe_artifact_idx);
     }
 
-    let releases = releases.into_iter().map(|e| e.1).collect();
+    // Add installers (currently all 1:1 with releases rather than targets)
+    for ((app_name, version), (package_id, release)) in &mut releases {
+        let repo = pkg_graph
+            .metadata(package_id)
+            .ok()
+            .and_then(|pkg| pkg.repository());
+        for installer in &cfg.installers {
+            let file_path;
+            let file_name;
+            let installer_impl;
+
+            match installer {
+                InstallerStyle::GithubShell => {
+                    file_name = "installer.sh".to_owned();
+                    file_path = dist_dir.join(&file_name);
+                    installer_impl = InstallerImpl::GithubShell(InstallerInfo {
+                        app_name: app_name.clone(),
+                        app_version: format!("v{version}"),
+                        repo_url: repo.unwrap_or("").to_owned(),
+                    });
+                }
+                InstallerStyle::GithubPowershell => {
+                    file_name = "installer.ps".to_owned();
+                    file_path = dist_dir.join(&file_name);
+                    installer_impl = InstallerImpl::GithubPowershell(InstallerInfo {
+                        app_name: app_name.clone(),
+                        app_version: format!("v{version}"),
+                        repo_url: repo.unwrap_or("").to_owned(),
+                    });
+                }
+            }
+
+            let installer_artifact_idx = ArtifactTargetIdx(artifacts.len());
+            artifacts.push(ArtifactTarget {
+                target_triple: None,
+                dir_name: None,
+                dir_path: None,
+                file_path,
+                file_name,
+                built_assets: HashMap::new(),
+                bundle: BundleStyle::Installer(installer_impl),
+                static_assets: vec![],
+                kind: ArtifactKind::Installer,
+            });
+            release.artifacts.push(installer_artifact_idx);
+        }
+    }
+
+    let releases = releases.into_iter().map(|e| e.1 .1).collect();
     Ok(DistGraph {
         cargo,
         target_dir,
@@ -572,7 +656,7 @@ fn gather_work(cfg: &Config) -> Result<DistGraph> {
     })
 }
 
-fn build_manifest(dist: &DistGraph) -> DistManifest {
+fn build_manifest(cfg: &Config, dist: &DistGraph) -> DistManifest {
     // Report the releases
     let mut releases = vec![];
     for release in &dist.releases {
@@ -586,8 +670,12 @@ fn build_manifest(dist: &DistGraph) -> DistManifest {
                     let artifact = &dist.artifacts[artifact_idx.0];
                     Artifact {
                         name: artifact.file_path.file_name().unwrap().to_owned(),
-                        path: Some(artifact.file_path.clone().into_std_path_buf()),
-                        target_triple: Some(artifact.target_triple.clone()),
+                        path: if cfg.no_local_paths {
+                            None
+                        } else {
+                            Some(artifact.file_path.clone().into_std_path_buf())
+                        },
+                        target_triple: artifact.target_triple.clone(),
                         assets: artifact
                             .built_assets
                             .iter()
@@ -692,6 +780,7 @@ fn build_cargo_target(
         target.target_triple, target.profile
     );
     // Run the build
+    let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
 
     // TODO: figure out a principled way for us to add things to RUSTFLAGS
     // without breaking everything. Cargo has some builtin ways like keys
@@ -727,6 +816,10 @@ fn build_cargo_target(
     // you can be compatible with any system at least that new (so the older the SDK,
     // the more compatible you are). Do we need to care about that? More Research Needed.
 
+    if target.target_triple.contains("windows-msvc") {
+        rustflags.push_str(" -Ctarget-feature=+crt-static");
+    }
+
     // TODO: maybe set RUSTFLAGS="-Cforce-frame-pointers=yes"
     //
     // On linux and macos this can make the unwind tables (debuginfo) smaller, more reliable,
@@ -788,6 +881,9 @@ fn build_cargo_target(
         .arg("--profile")
         .arg(&target.profile)
         .arg("--message-format=json")
+        .arg("--target")
+        .arg(&target.target_triple)
+        .env("RUSTFLAGS", rustflags)
         .stdout(std::process::Stdio::piped());
     if target.features.no_default_features {
         command.arg("--no-default-features");
@@ -923,18 +1019,6 @@ fn build_cargo_target(
 
 /// Initialize the dir for an artifact (and delete the old artifact file).
 fn init_artifact_dir(_dist: &DistGraph, artifact: &ArtifactTarget) -> Result<()> {
-    info!("recreating artifact dir: {}", artifact.dir_path);
-
-    // Clear out the dir we'll build the bundle up in
-    if artifact.dir_path.exists() {
-        std::fs::remove_dir_all(&artifact.dir_path)
-            .into_diagnostic()
-            .wrap_err_with(|| format!("failed to delete old artifact dir {}", artifact.dir_path))?;
-    }
-    std::fs::create_dir(&artifact.dir_path)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to create artifact dir {}", artifact.dir_path))?;
-
     // Delete any existing bundle
     if artifact.file_path.exists() {
         std::fs::remove_file(&artifact.file_path)
@@ -942,6 +1026,22 @@ fn init_artifact_dir(_dist: &DistGraph, artifact: &ArtifactTarget) -> Result<()>
             .wrap_err_with(|| format!("failed to delete old artifact {}", artifact.file_path))?;
     }
 
+    let Some(artifact_dir_path) = &artifact.dir_path else {
+        // If there's no dir than we're done
+        return Ok(());
+    };
+    info!("recreating artifact dir: {artifact_dir_path}");
+
+    // Clear out the dir we'll build the bundle up in
+    if artifact_dir_path.exists() {
+        std::fs::remove_dir_all(artifact_dir_path)
+            .into_diagnostic()
+            .wrap_err_with(|| format!("failed to delete old artifact dir {artifact_dir_path}"))?;
+    }
+    std::fs::create_dir(artifact_dir_path)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to create artifact dir {artifact_dir_path}"))?;
+
     Ok(())
 }
 
@@ -954,11 +1054,15 @@ fn populate_artifact_dirs_with_built_assets(
         if let Some(rel_asset_path) = artifact.built_assets.get(&built_asset_idx) {
             let bundled_asset = if let BundleStyle::UncompressedFile = artifact.bundle {
                 // If the asset is a single uncompressed file, we can just copy it to its final dest
-                info!("  copying {built_asset_path} to {}", artifact.dir_path);
-                artifact.dir_path.join(&artifact.file_path)
+                info!("  copying {built_asset_path} to {}", artifact.file_path);
+                artifact.file_path.clone()
             } else {
-                info!("  adding {built_asset_path} to {}", artifact.dir_path);
-                artifact.dir_path.join(rel_asset_path)
+                let artifact_dir_path = artifact
+                    .dir_path
+                    .as_ref()
+                    .expect("compressed bundle didn't have a dir path?!");
+                info!("  adding {built_asset_path} to {}", artifact_dir_path);
+                artifact_dir_path.join(rel_asset_path)
             };
 
             std::fs::copy(built_asset_path, &bundled_asset)
@@ -977,11 +1081,17 @@ fn populate_artifact_dir_with_static_assets(
     _dist: &DistGraph,
     artifact: &ArtifactTarget,
 ) -> Result<()> {
-    info!("populating artifact dir: {}", artifact.dir_path);
+    let Some(artifact_dir_path) = &artifact.dir_path else {
+        assert!(artifact.static_assets.is_empty(), "had static assets but didn't have a dir path?!");
+        // If there's no dir than we're done
+        return Ok(());
+    };
+
+    info!("populating artifact dir: {}", artifact_dir_path);
     // Copy assets
     for asset in &artifact.static_assets {
         let asset_file_name = asset.file_name().unwrap();
-        let bundled_asset = artifact.dir_path.join(asset_file_name);
+        let bundled_asset = artifact_dir_path.join(asset_file_name);
         info!("  adding {bundled_asset}");
         std::fs::copy(asset, &bundled_asset)
             .into_diagnostic()
@@ -998,6 +1108,7 @@ fn bundle_artifact(dist_graph: &DistGraph, artifact: &ArtifactTarget) -> Result<
     match &artifact.bundle {
         BundleStyle::Zip => zip_artifact(dist_graph, artifact),
         BundleStyle::Tar(compression) => tar_artifact(dist_graph, artifact, compression),
+        BundleStyle::Installer(installer) => generate_installer(dist_graph, artifact, installer),
         BundleStyle::UncompressedFile => {
             // Already handled by populate_artifact_dirs_with_built_assets
             info!("artifact created at: {}", artifact.file_path);
@@ -1013,7 +1124,8 @@ fn tar_artifact(
 ) -> Result<()> {
     // Set up the archive/compression
     // The contents of the zip (e.g. a tar)
-    let artifact_dir_name = &artifact.full_name;
+    let artifact_dir_path = artifact.dir_path.as_ref().unwrap();
+    let artifact_dir_name = &artifact.dir_name.as_ref().unwrap();
     let zip_contents_name = format!("{artifact_dir_name}.tar");
     let final_zip_path = &artifact.file_path;
     let final_zip_file = File::create(final_zip_path)
@@ -1031,12 +1143,11 @@ fn tar_artifact(
             let mut tar = tar::Builder::new(zip_output);
 
             // Add the whole dir to the tar
-            tar.append_dir_all(artifact_dir_name, &artifact.dir_path)
+            tar.append_dir_all(artifact_dir_name, artifact_dir_path)
                 .into_diagnostic()
                 .wrap_err_with(|| {
                     format!(
-                        "failed to copy directory into tar: {} => {}",
-                        artifact.dir_path, artifact_dir_name
+                        "failed to copy directory into tar: {artifact_dir_path} => {artifact_dir_name}",
                     )
                 })?;
             // Finish up the tarring
@@ -1057,12 +1168,11 @@ fn tar_artifact(
             let mut tar = tar::Builder::new(zip_output);
 
             // Add the whole dir to the tar
-            tar.append_dir_all(artifact_dir_name, &artifact.dir_path)
+            tar.append_dir_all(artifact_dir_name, artifact_dir_path)
                 .into_diagnostic()
                 .wrap_err_with(|| {
                     format!(
-                        "failed to copy directory into tar: {} => {}",
-                        artifact.dir_path, artifact_dir_name
+                        "failed to copy directory into tar: {artifact_dir_path} => {artifact_dir_name}",
                     )
                 })?;
             // Finish up the tarring
@@ -1085,12 +1195,11 @@ fn tar_artifact(
             let mut tar = tar::Builder::new(zip_output);
 
             // Add the whole dir to the tar
-            tar.append_dir_all(artifact_dir_name, &artifact.dir_path)
+            tar.append_dir_all(artifact_dir_name, artifact_dir_path)
                 .into_diagnostic()
                 .wrap_err_with(|| {
                     format!(
-                        "failed to copy directory into tar: {} => {}",
-                        artifact.dir_path, artifact_dir_name
+                        "failed to copy directory into tar: {artifact_dir_path} => {artifact_dir_name}",
                     )
                 })?;
             // Finish up the tarring
@@ -1113,6 +1222,7 @@ fn tar_artifact(
 
 fn zip_artifact(_dist_graph: &DistGraph, artifact: &ArtifactTarget) -> Result<()> {
     // Set up the archive/compression
+    let artifact_dir_path = artifact.dir_path.as_ref().unwrap();
     let final_zip_path = &artifact.file_path;
     let final_zip_file = File::create(final_zip_path)
         .into_diagnostic()
@@ -1121,9 +1231,9 @@ fn zip_artifact(_dist_graph: &DistGraph, artifact: &ArtifactTarget) -> Result<()
     // Wrap our file in compression
     let mut zip = ZipWriter::new(final_zip_file);
 
-    let dir = std::fs::read_dir(&artifact.dir_path)
+    let dir = std::fs::read_dir(artifact_dir_path)
         .into_diagnostic()
-        .wrap_err_with(|| format!("failed to read artifact dir: {}", artifact.dir_path))?;
+        .wrap_err_with(|| format!("failed to read artifact dir: {artifact_dir_path}"))?;
     for entry in dir {
         let entry = entry.into_diagnostic()?;
         if entry.file_type().into_diagnostic()?.is_file() {
@@ -1465,8 +1575,24 @@ pub fn do_generate_ci(cfg: &Config, args: &GenerateCiArgs) -> Result<()> {
     let graph = gather_work(cfg)?;
     for style in &args.ci_styles {
         match style {
-            CiStyle::Github => ci::generate_github_ci(&graph.workspace_dir, &cfg.targets)?,
+            CiStyle::Github => {
+                ci::generate_github_ci(&graph.workspace_dir, &cfg.targets, &cfg.installers)?
+            }
         }
     }
     Ok(())
 }
+
+/// Build a cargo target
+fn generate_installer(
+    _dist_graph: &DistGraph,
+    target: &ArtifactTarget,
+    style: &InstallerImpl,
+) -> Result<()> {
+    match style {
+        InstallerImpl::GithubShell(info) => {
+            installer::generate_github_install_sh_script(target, info)
+        }
+        InstallerImpl::GithubPowershell(_) => todo!(),
+    }
+}
diff --git a/cargo-dist/src/main.rs b/cargo-dist/src/main.rs
index a5ca7eab..1cb9325f 100644
--- a/cargo-dist/src/main.rs
+++ b/cargo-dist/src/main.rs
@@ -141,9 +141,12 @@ fn print_json(out: &mut Term, report: &DistManifest) -> Result<(), std::io::Erro
     Ok(())
 }
 
-fn cmd_dist(cli: &Cli, _args: &BuildArgs) -> Result<(), miette::Report> {
+fn cmd_dist(cli: &Cli, args: &BuildArgs) -> Result<(), miette::Report> {
     let config = cargo_dist::Config {
+        build: !args.no_builds,
+        no_local_paths: cli.no_local_paths,
         targets: cli.target.clone(),
+        installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(),
     };
     let report = do_dist(&config)?;
     let mut out = Term::stdout();
@@ -156,7 +159,10 @@ fn cmd_dist(cli: &Cli, _args: &BuildArgs) -> Result<(), miette::Report> {
 
 fn cmd_manifest(cli: &Cli, _args: &ManifestArgs) -> Result<(), miette::Report> {
     let config = cargo_dist::Config {
+        build: true,
+        no_local_paths: cli.no_local_paths,
         targets: cli.target.clone(),
+        installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(),
     };
     let report = do_manifest(&config)?;
     let mut out = Term::stdout();
@@ -174,7 +180,12 @@ fn cmd_init(cli: &Cli, args: &InitArgs) -> Result<(), miette::Report> {
     } else {
         cli.target.clone()
     };
-    let config = cargo_dist::Config { targets };
+    let config = cargo_dist::Config {
+        build: true,
+        no_local_paths: cli.no_local_paths,
+        targets,
+        installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(),
+    };
     let args = cargo_dist::InitArgs {
         ci_styles: args.ci.iter().map(|ci| ci.to_lib()).collect(),
     };
@@ -193,7 +204,12 @@ fn cmd_generate_ci(cli: &Cli, args: &GenerateCiArgs) -> Result<(), miette::Repor
     } else {
         cli.target.clone()
     };
-    let config = cargo_dist::Config { targets };
+    let config = cargo_dist::Config {
+        build: true,
+        no_local_paths: cli.no_local_paths,
+        targets,
+        installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(),
+    };
     let args = cargo_dist::GenerateCiArgs {
         ci_styles: args.style.iter().map(|ci| ci.to_lib()).collect(),
     };
@@ -202,10 +218,10 @@ fn cmd_generate_ci(cli: &Cli, args: &GenerateCiArgs) -> Result<(), miette::Repor
 
 fn default_desktop_targets() -> Vec<String> {
     vec![
-        "x86_64-unknown-linux-gnu".to_owned(),
+        "x86_64-unknown-linux-musl".to_owned(),
         "x86_64-apple-darwin".to_owned(),
         "x86_64-pc-windows-msvc".to_owned(),
-        // cross-compile not yet supported
+        // cross-compiles not yet supported
         // "aarch64-gnu-unknown-linux".to_owned(),
         // "aarch64-pc-windows-msvc".to_owned(),
         // "aarch64-apple-darwin".to_owned(),


Test patch:
diff --git a/cargo-dist/tests/cli-tests.rs b/cargo-dist/tests/cli-tests.rs
index 485112d3..003904f8 100644
--- a/cargo-dist/tests/cli-tests.rs
+++ b/cargo-dist/tests/cli-tests.rs
@@ -66,3 +66,23 @@ fn test_short_help() {
     insta::assert_snapshot!("short-help", format_outputs(&output));
     assert!(output.status.success(), "{}", output.status);
 }
+
+#[test]
+fn test_manifest() {
+    let output = Command::new(BIN)
+        .arg("dist")
+        .arg("manifest")
+        .arg("--no-local-paths")
+        .arg("--output-format=json")
+        .arg("--target=x86_64-pc-windows-msvc")
+        .arg("--target=x86_64-apple-darwin")
+        .arg("--target=x86_64-unknown-linux-gnu")
+        .arg("--installer=github-shell")
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .output()
+        .unwrap();
+
+    insta::assert_snapshot!(format_outputs(&output));
+    assert!(output.status.success(), "{}", output.status);
+}
diff --git a/cargo-dist/tests/snapshots/cli_tests__long-help.snap b/cargo-dist/tests/snapshots/cli_tests__long-help.snap
index 370561c3..71580600 100644
--- a/cargo-dist/tests/snapshots/cli_tests__long-help.snap
+++ b/cargo-dist/tests/snapshots/cli_tests__long-help.snap
@@ -23,6 +23,9 @@ Commands:
           Print this message or the help of the given subcommand(s)
 
 Options:
+      --no-builds
+          Don't actually do any builds, this can be useful for generating only installers
+
   -h, --help
           Print help information (use `-h` for a summary)
 
@@ -42,8 +45,18 @@ GLOBAL OPTIONS:
           [default: human]
           [possible values: human, json]
 
+      --no-local-paths
+          Strip local paths from output (e.g. in the dist manifest json)
+
       --target <TARGET>
           Target triples we want to build
 
+      --installer <INSTALLER>
+          Installers we want to build
+
+          Possible values:
+          - github-shell:      Generates a shell script that fetches from github ci
+          - github-powershell: Generates a powershell script that fetches from github ci
+
 stderr:
 
diff --git a/cargo-dist/tests/snapshots/cli_tests__manifest.snap b/cargo-dist/tests/snapshots/cli_tests__manifest.snap
new file mode 100644
index 00000000..ab38d765
--- /dev/null
+++ b/cargo-dist/tests/snapshots/cli_tests__manifest.snap
@@ -0,0 +1,64 @@
+---
+source: cargo-dist/tests/cli-tests.rs
+expression: format_outputs(&output)
+---
+stdout:
+{
+  "releases": [
+    {
+      "app_name": "cargo-dist",
+      "app_version": "0.2.1-prerelease4",
+      "artifacts": [
+        {
+          "name": "cargo-dist-v0.2.1-prerelease4-x86_64-pc-windows-msvc.pdb",
+          "kind": "symbols",
+          "target_triple": "x86_64-pc-windows-msvc"
+        },
+        {
+          "name": "cargo-dist-v0.2.1-prerelease4-x86_64-pc-windows-msvc.zip",
+          "kind": "executable-zip",
+          "target_triple": "x86_64-pc-windows-msvc",
+          "assets": [
+            {
+              "name": "cargo-dist",
+              "path": "cargo-dist.exe",
+              "kind": "executable",
+              "symbols_artifact": "cargo-dist-v0.2.1-prerelease4-x86_64-pc-windows-msvc.pdb"
+            }
+          ]
+        },
+        {
+          "name": "cargo-dist-v0.2.1-prerelease4-x86_64-apple-darwin.tar.xz",
+          "kind": "executable-zip",
+          "target_triple": "x86_64-apple-darwin",
+          "assets": [
+            {
+              "name": "cargo-dist",
+              "path": "cargo-dist",
+              "kind": "executable"
+            }
+          ]
+        },
+        {
+          "name": "cargo-dist-v0.2.1-prerelease4-x86_64-unknown-linux-gnu.tar.xz",
+          "kind": "executable-zip",
+          "target_triple": "x86_64-unknown-linux-gnu",
+          "assets": [
+            {
+              "name": "cargo-dist",
+              "path": "cargo-dist",
+              "kind": "executable"
+            }
+          ]
+        },
+        {
+          "name": "installer.sh",
+          "kind": "installer"
+        }
+      ]
+    }
+  ]
+}
+
+stderr:
+
diff --git a/cargo-dist/tests/snapshots/cli_tests__short-help.snap b/cargo-dist/tests/snapshots/cli_tests__short-help.snap
index debb5e9d..c6e02c20 100644
--- a/cargo-dist/tests/snapshots/cli_tests__short-help.snap
+++ b/cargo-dist/tests/snapshots/cli_tests__short-help.snap
@@ -16,13 +16,16 @@ Commands:
   help         Print this message or the help of the given subcommand(s)
 
 Options:
-  -h, --help     Print help information (use `--help` for more detail)
-  -V, --version  Print version information
+      --no-builds  Don't actually do any builds, this can be useful for generating only installers
+  -h, --help       Print help information (use `--help` for more detail)
+  -V, --version    Print version information
 
 GLOBAL OPTIONS:
       --verbose <VERBOSE>              How verbose logging should be (log level) [default: warn] [possible values: off, error, warn, info, debug, trace]
       --output-format <OUTPUT_FORMAT>  The format of the output [default: human] [possible values: human, json]
+      --no-local-paths                 Strip local paths from output (e.g. in the dist manifest json)
       --target <TARGET>                Target triples we want to build
+      --installer <INSTALLER>          Installers we want to build [possible values: github-shell, github-powershell]
 
 stderr: