Repository: axodotdev/cargo-dist (rust)

Issue:
generate++
Improve the `generate` system as follows:

##  Replace `generate-ci` with a `generate` command that takes a list of `GenerateStyle`

* "ci" being the one and only GenerateStyle
* in the future "wix" will be added to this list by #370 
* if no GenerateStyles are passed, the command is in "all mode", meaning "all GenerateStyles that this workspace targets"
  * Similar concept already exists for places you can pass InstallerStyles, CiStyles, and Targets
  * As with those, we should arguably also clamp the GenerateStyle lists to the "all" set 
* (Optional) keep a generate-ci alias for back-compat

## Add some mode flags to `generate`

* `--check` changes the default behaviour from "write to disk" to "error if writing to disk would change the file"
* (Optional) `--print` flag that just prints what would be written to disk 
   * with `==== package/main.wxs ====` headings or something?

## Add `allow-dirty: Option<Vec<GenerateStyle>>` to config

* Tells `generate` "all mode" to skip that style
* This implies `generate --check` won't complain about it
* Unsure: can non-all-mode override allow-dirty?
* Usure: Should there be a flag to disable allow-dirty?
    
## Make `generate --check` block most other commands

* `manifest`, `plan`, and `check` should all run `generate --check` on their DistGraphs before proceeding
* (obviously not the full CLI command but the subroutine that picks up from the DistGraph)

Pull request description:
feat: generate++
Closes #371.

This version covers steps 1 and 2, but not 3 and 4 yet.

Patch:
diff --git a/cargo-dist-schema/src/lib.rs b/cargo-dist-schema/src/lib.rs
index 2e8970c3..68624307 100644
--- a/cargo-dist-schema/src/lib.rs
+++ b/cargo-dist-schema/src/lib.rs
@@ -89,6 +89,10 @@ pub struct GithubCiInfo {
     /// What kind of job to run on pull request
     #[serde(skip_serializing_if = "Option::is_none")]
     pub pr_run_mode: Option<PrRunMode>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    /// Whether to skip the check for outdated configuration
+    pub allow_dirty: Option<bool>,
 }
 
 /// Github CI Matrix
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 8c8f2f23..a0a19811 100644
--- a/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
+++ b/cargo-dist-schema/src/snapshots/cargo_dist_schema__emit.snap
@@ -343,6 +343,13 @@ expression: json_schema
       "description": "Github CI backend",
       "type": "object",
       "properties": {
+        "allow_dirty": {
+          "description": "Whether to skip the check for outdated configuration",
+          "type": [
+            "boolean",
+            "null"
+          ]
+        },
         "artifacts_matrix": {
           "description": "Github CI Matrix for upload-artifacts",
           "anyOf": [
diff --git a/cargo-dist/src/backend/ci/github.rs b/cargo-dist/src/backend/ci/github.rs
index 61ff1771..ffbed8b3 100644
--- a/cargo-dist/src/backend/ci/github.rs
+++ b/cargo-dist/src/backend/ci/github.rs
@@ -10,9 +10,15 @@ use serde::Serialize;
 use tracing::warn;
 
 use crate::{
-    backend::templates::TEMPLATE_CI_GITHUB, DistGraph, SortedMap, SortedSet, TargetTriple,
+    backend::templates::TEMPLATE_CI_GITHUB,
+    config::CiStyle,
+    errors::{DistError, DistResult},
+    DistGraph, SortedMap, SortedSet, TargetTriple,
 };
 
+const GITHUB_CI_DIR: &str = ".github/workflows/";
+const GITHUB_CI_FILE: &str = "release.yml";
+
 /// Info about running cargo-dist in Github CI
 #[derive(Debug, Serialize)]
 pub struct GithubCiInfo {
@@ -36,6 +42,8 @@ pub struct GithubCiInfo {
     pub publish_jobs: Vec<String>,
     /// whether to create the release or assume an existing one
     pub create_release: bool,
+    /// whether to ignore on-disk changes to the configuration
+    pub allow_dirty: bool,
 }
 
 impl GithubCiInfo {
@@ -84,6 +92,7 @@ impl GithubCiInfo {
         };
 
         let pr_run_mode = dist.pr_run_mode.clone();
+        let allow_dirty = dist.allow_dirty.contains(&CiStyle::Github);
 
         let tap = dist.tap.clone();
         let publish_jobs = dist.publish_jobs.iter().map(|j| j.to_string()).collect();
@@ -120,26 +129,51 @@ impl GithubCiInfo {
             pr_run_mode,
             global_task,
             create_release,
+            allow_dirty,
         }
     }
 
-    /// Write release.yml to disk
-    pub fn write_to_disk(&self, dist: &DistGraph) -> Result<(), miette::Report> {
-        const GITHUB_CI_DIR: &str = ".github/workflows/";
-        const GITHUB_CI_FILE: &str = "release.yml";
-
-        // FIXME: should we try to avoid clobbering old files..?
+    fn github_ci_path(&self, dist: &DistGraph) -> camino::Utf8PathBuf {
         let ci_dir = dist.workspace_dir.join(GITHUB_CI_DIR);
-        let ci_file = ci_dir.join(GITHUB_CI_FILE);
+        ci_dir.join(GITHUB_CI_FILE)
+    }
 
+    /// Generate the requested configuration and returns it as a string.
+    pub fn generate_github_ci(&self, dist: &DistGraph) -> DistResult<String> {
         let rendered = dist
             .templates
             .render_file_to_clean_string(TEMPLATE_CI_GITHUB, self)?;
+
+        Ok(rendered)
+    }
+
+    /// Write release.yml to disk
+    pub fn write_to_disk(&self, dist: &DistGraph) -> Result<(), miette::Report> {
+        let ci_file = self.github_ci_path(dist);
+        let rendered = self.generate_github_ci(dist)?;
+
         LocalAsset::write_new_all(&rendered, &ci_file)?;
         eprintln!("generated Github CI to {}", ci_file);
 
         Ok(())
     }
+
+    /// Check whether the new configuration differs from the config on disk
+    /// writhout actually writing the result.
+    pub fn check_github_ci(&self, dist: &DistGraph) -> DistResult<()> {
+        let ci_file = self.github_ci_path(dist);
+
+        let rendered = self.generate_github_ci(dist)?;
+        // FIXME: should we catch all errors, or only LocalAssetNotFound?
+        let existing = LocalAsset::load_string(&ci_file).unwrap_or("".to_owned());
+        if rendered != existing && !self.allow_dirty {
+            Err(DistError::CheckFileMismatch {
+                file: ci_file.to_string(),
+            })
+        } else {
+            Ok(())
+        }
+    }
 }
 
 /// Given a set of targets we want to build local artifacts for, map them to Github Runners
diff --git a/cargo-dist/src/cli.rs b/cargo-dist/src/cli.rs
index e03f8f23..cea63692 100644
--- a/cargo-dist/src/cli.rs
+++ b/cargo-dist/src/cli.rs
@@ -117,7 +117,10 @@ pub enum Commands {
     /// also handle updating your project to a new version of cargo-dist if you're running one.
     #[clap(disable_version_flag = true)]
     Init(InitArgs),
-    /// Generate CI scripts for orchestrating cargo-dist
+    /// Generate one or more pieces of configuration
+    #[clap(disable_version_flag = true)]
+    Generate(GenerateArgs),
+    /// Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate)
     #[clap(disable_version_flag = true)]
     GenerateCi(GenerateCiArgs),
     /// Generate the final build manifest without running any builds.
@@ -233,8 +236,40 @@ pub struct InitArgs {
     pub with_json_config: Option<Utf8PathBuf>,
 }
 
+/// Which style(s) of configuration to generate
+#[derive(ValueEnum, Copy, Clone, Debug)]
+pub enum GenerateMode {
+    /// Generate CI scripts for orchestrating cargo-dist
+    Ci,
+}
+
+impl GenerateMode {
+    /// Convert the application version of this enum to the library version
+    pub fn to_lib(self) -> cargo_dist::config::GenerateMode {
+        match self {
+            GenerateMode::Ci => cargo_dist::config::GenerateMode::Ci,
+        }
+    }
+}
+
 #[derive(Args, Clone, Debug)]
-pub struct GenerateCiArgs {}
+pub struct GenerateArgs {
+    /// Which type of configuration to generate
+    pub mode: Vec<GenerateMode>,
+
+    /// Check if the generated output differs from on-disk config without writing it
+    #[clap(long)]
+    #[clap(default_value_t = false)]
+    pub check: bool,
+}
+
+#[derive(Args, Clone, Debug)]
+pub struct GenerateCiArgs {
+    /// Check if the generated output differs from on-disk config without writing it
+    #[clap(long)]
+    #[clap(default_value_t = false)]
+    pub check: bool,
+}
 
 #[derive(Args, Clone, Debug)]
 pub struct HelpMarkdownArgs {}
diff --git a/cargo-dist/src/config.rs b/cargo-dist/src/config.rs
index 5d168e25..a795ed2f 100644
--- a/cargo-dist/src/config.rs
+++ b/cargo-dist/src/config.rs
@@ -60,6 +60,12 @@ pub struct DistMetadata {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub pr_run_mode: Option<cargo_dist_schema::PrRunMode>,
 
+    /// CI environments whose configuration 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<CiStyle>>,
+
     /// The full set of installers you would like to produce
     ///
     /// When generating full task graphs (such as CI scripts) we will try to generate these.
@@ -275,6 +281,7 @@ impl DistMetadata {
             publish_jobs: _,
             create_release: _,
             pr_run_mode: _,
+            allow_dirty: _,
         } = self;
         if let Some(include) = include {
             for include in include {
@@ -314,6 +321,7 @@ impl DistMetadata {
             publish_jobs,
             create_release,
             pr_run_mode: _,
+            allow_dirty,
         } = self;
 
         // Check for global settings on local packages
@@ -382,6 +390,9 @@ impl DistMetadata {
         if publish_jobs.is_none() {
             *publish_jobs = workspace_config.publish_jobs.clone();
         }
+        if allow_dirty.is_none() {
+            *allow_dirty = workspace_config.allow_dirty.clone();
+        }
 
         // This was historically implemented as extend, but I'm not convinced the
         // inconsistency is worth the inconvenience...
@@ -716,6 +727,13 @@ impl ChecksumStyle {
     }
 }
 
+/// Which style(s) of configuration to generate
+#[derive(Copy, Clone, Debug)]
+pub enum GenerateMode {
+    /// Generate CI scripts for orchestrating cargo-dist
+    Ci,
+}
+
 pub(crate) fn parse_metadata_table(
     manifest_path: &Utf8Path,
     metadata_table: Option<&serde_json::Value>,
diff --git a/cargo-dist/src/errors.rs b/cargo-dist/src/errors.rs
index 15831b79..a38e0e0d 100644
--- a/cargo-dist/src/errors.rs
+++ b/cargo-dist/src/errors.rs
@@ -159,6 +159,13 @@ pub enum DistError {
         #[source]
         details: semver::Error,
     },
+
+    /// Not an error; indicates that a file's contents differ via --check
+    #[error(r#"generated contents for file {file} differ"#)]
+    CheckFileMismatch {
+        /// The file whose contents differ
+        file: String,
+    },
 }
 
 impl From<minijinja::Error> for DistError {
diff --git a/cargo-dist/src/init.rs b/cargo-dist/src/init.rs
index 3a51e93f..be7c927f 100644
--- a/cargo-dist/src/init.rs
+++ b/cargo-dist/src/init.rs
@@ -111,7 +111,7 @@ pub fn do_init(cfg: &Config, args: &InitArgs) -> Result<()> {
             eprintln!("running 'cargo dist generate-ci' to apply any changes to your CI scripts");
             eprintln!();
 
-            let ci_args = GenerateCiArgs {};
+            let ci_args = GenerateCiArgs { check: false };
             do_generate_ci(cfg, &ci_args)?;
         }
     }
@@ -207,6 +207,7 @@ fn get_new_dist_metadata(
             publish_jobs: None,
             create_release: None,
             pr_run_mode: None,
+            allow_dirty: None,
         }
     };
 
@@ -661,6 +662,7 @@ fn update_toml_metadata(
         publish_jobs,
         create_release,
         pr_run_mode,
+        allow_dirty,
     } = &meta;
 
     apply_optional_value(
@@ -824,6 +826,13 @@ fn update_toml_metadata(
         pr_run_mode.as_ref().map(|m| m.to_string()),
     );
 
+    apply_string_list(
+        table,
+        "allow-dirty",
+        "# Skip checking whether the specified configuration files are up to date\n",
+        allow_dirty.as_ref(),
+    );
+
     // Finalize the table
     table
         .decor_mut()
diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs
index ab625d53..b0609483 100644
--- a/cargo-dist/src/lib.rs
+++ b/cargo-dist/src/lib.rs
@@ -23,7 +23,7 @@ use backend::{
 };
 use camino::{Utf8Path, Utf8PathBuf};
 use cargo_dist_schema::{Asset, AssetKind, DistManifest, ExecutableAsset};
-use config::{ChecksumStyle, CompressionImpl, Config, ZipStyle};
+use config::{ChecksumStyle, CompressionImpl, Config, GenerateMode, ZipStyle};
 use semver::Version;
 use tracing::{info, warn};
 
@@ -50,6 +50,12 @@ pub fn do_dist(cfg: &Config) -> Result<DistManifest> {
         ));
     }
 
+    // If you add a CI backend, call its check here
+    let CiInfo { github } = &dist.ci;
+    if let Some(github) = github {
+        github.check_github_ci(&dist)?;
+    }
+
     // 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
     // parallelize Cargo invocations because it has global state that can get clobbered.
@@ -143,6 +149,7 @@ fn build_manifest(cfg: &Config, dist: &DistGraph) -> DistManifest {
         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.clone()),
+            allow_dirty: Some(info.allow_dirty),
         });
 
         manifest.ci = Some(cargo_dist_schema::CiInfo { github });
@@ -571,13 +578,16 @@ fn zip_dir(
     Ok(())
 }
 
-/// Arguments for `cargo dist generate-ci` ([`do_generate_ci][])
+/// Arguments for `cargo dist generate` ([`do_generate][])
 #[derive(Debug)]
-pub struct GenerateCiArgs {}
+pub struct GenerateArgs {
+    /// Check whether the output differs without writing to disk
+    pub check: bool,
+    /// Which type(s) of config to generate
+    pub modes: Vec<GenerateMode>,
+}
 
-/// Generate CI scripts (impl of `cargo dist generate-ci`)
-pub fn do_generate_ci(cfg: &Config, _args: &GenerateCiArgs) -> Result<()> {
-    let dist = gather_work(cfg)?;
+fn do_generate_preflight_checks(dist: &DistGraph) -> Result<()> {
     // Enforce cargo-dist-version, unless it's a magic vX.Y.Z-github-BRANCHNAME version,
     // which we use for testing against a PR branch. In that case the current_version
     // should be irrelevant (so sayeth the person who made and uses this feature).
@@ -593,10 +603,67 @@ pub fn do_generate_ci(cfg: &Config, _args: &GenerateCiArgs) -> Result<()> {
         ));
     }
 
+    Ok(())
+}
+
+/// 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)?;
+    do_generate_preflight_checks(&dist)?;
+
+    // If specific modes are specified, operate *only* on those modes
+    // Otherwise, choose any modes that are appropriate
+    let inferred = args.modes.is_empty();
+    let modes = if inferred {
+        let mut m = vec![];
+        // CI is the only thing to infer at the moment
+        if !&dist.ci_style.is_empty() {
+            m.push(GenerateMode::Ci)
+        }
+        m
+    } else {
+        args.modes.clone()
+    };
+
+    for mode in modes {
+        match mode {
+            GenerateMode::Ci => {
+                // If you add a CI backend, call it here
+                let CiInfo { github } = &dist.ci;
+                if let Some(github) = github {
+                    // Always write if not inferred, otherwise only write
+                    // if allow_dirty is off.
+                    if !inferred || !github.allow_dirty {
+                        github.write_to_disk(&dist)?;
+                    }
+                }
+            }
+        }
+    }
+
+    Ok(())
+}
+
+/// Arguments for `cargo dist generate-ci` ([`do_generate_ci][])
+#[derive(Debug)]
+pub struct GenerateCiArgs {
+    /// Check whether the output differs without writing to disk
+    pub check: bool,
+}
+
+/// Generate CI scripts (impl of `cargo dist generate-ci`)
+pub fn do_generate_ci(cfg: &Config, args: &GenerateCiArgs) -> Result<()> {
+    let dist = gather_work(cfg)?;
+    do_generate_preflight_checks(&dist)?;
+
     // If you add a CI backend, call its write_to_disk here
     let CiInfo { github } = &dist.ci;
     if let Some(github) = github {
-        github.write_to_disk(&dist)?;
+        if args.check {
+            github.check_github_ci(&dist)?;
+        } else {
+            github.write_to_disk(&dist)?;
+        }
     }
     Ok(())
 }
diff --git a/cargo-dist/src/main.rs b/cargo-dist/src/main.rs
index 076a6848..a5a765e3 100644
--- a/cargo-dist/src/main.rs
+++ b/cargo-dist/src/main.rs
@@ -9,11 +9,13 @@ use camino::Utf8PathBuf;
 use cargo_dist::*;
 use cargo_dist_schema::{AssetKind, DistManifest};
 use clap::Parser;
-use cli::{Cli, Commands, FakeCli, HelpMarkdownArgs, ManifestArgs, OutputFormat, PlanArgs};
+use cli::{
+    Cli, Commands, FakeCli, GenerateMode, HelpMarkdownArgs, ManifestArgs, OutputFormat, PlanArgs,
+};
 use console::Term;
 use miette::IntoDiagnostic;
 
-use crate::cli::{BuildArgs, GenerateCiArgs, InitArgs};
+use crate::cli::{BuildArgs, GenerateArgs, GenerateCiArgs, InitArgs};
 
 mod cli;
 
@@ -29,6 +31,7 @@ fn real_main(cli: &axocli::CliApp<Cli>) -> Result<(), miette::Report> {
     let config = &cli.config;
     match &config.command {
         Commands::Init(args) => cmd_init(config, args),
+        Commands::Generate(args) => cmd_generate(config, args),
         Commands::GenerateCi(args) => cmd_generate_ci(config, args),
         Commands::Manifest(args) => cmd_manifest(config, args),
         Commands::Plan(args) => cmd_plan(config, args),
@@ -220,7 +223,7 @@ fn cmd_init(cli: &Cli, args: &InitArgs) -> Result<(), miette::Report> {
     do_init(&config, &args)
 }
 
-fn cmd_generate_ci(cli: &Cli, _args: &GenerateCiArgs) -> Result<(), miette::Report> {
+fn cmd_generate(cli: &Cli, args: &GenerateArgs) -> Result<(), miette::Report> {
     let config = cargo_dist::config::Config {
         needs_coherent_announcement_tag: false,
         artifact_mode: cargo_dist::config::ArtifactMode::All,
@@ -230,8 +233,21 @@ fn cmd_generate_ci(cli: &Cli, _args: &GenerateCiArgs) -> Result<(), miette::Repo
         installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(),
         announcement_tag: cli.tag.clone(),
     };
-    let args = cargo_dist::GenerateCiArgs {};
-    do_generate_ci(&config, &args)
+    let args = cargo_dist::GenerateArgs {
+        check: args.check,
+        modes: args.mode.iter().map(|m| m.to_lib()).collect(),
+    };
+    do_generate(&config, &args)
+}
+
+fn cmd_generate_ci(cli: &Cli, args: &GenerateCiArgs) -> Result<(), miette::Report> {
+    cmd_generate(
+        cli,
+        &GenerateArgs {
+            check: args.check,
+            mode: vec![GenerateMode::Ci],
+        },
+    )
 }
 
 fn default_desktop_targets() -> Vec<String> {
diff --git a/cargo-dist/src/tasks.rs b/cargo-dist/src/tasks.rs
index e97b4385..3e87ec77 100644
--- a/cargo-dist/src/tasks.rs
+++ b/cargo-dist/src/tasks.rs
@@ -163,6 +163,8 @@ pub struct DistGraph {
     pub ci_style: Vec<CiStyle>,
     /// TODO
     pub pr_run_mode: cargo_dist_schema::PrRunMode,
+    /// Styles of CI to skip configuration up to date checks for
+    pub allow_dirty: Vec<CiStyle>,
     /// The git tag used for the announcement (e.g. v1.0.0)
     ///
     /// This is important for certain URLs like Github Releases
@@ -640,6 +642,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
             all_features,
             create_release,
             pr_run_mode: _,
+            allow_dirty,
         } = &workspace_metadata;
 
         let desired_cargo_dist_version = cargo_dist_version.clone();
@@ -688,6 +691,8 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         let templates = Templates::new()?;
         let publish_jobs = workspace_metadata.publish_jobs.clone().unwrap_or(vec![]);
 
+        let allow_dirty = allow_dirty.clone().unwrap_or(vec![]);
+
         Ok(Self {
             inner: DistGraph {
                 is_init: dist_profile.is_some(),
@@ -718,6 +723,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
                 pr_run_mode: workspace_metadata.pr_run_mode.clone().unwrap_or_default(),
                 tap: workspace_metadata.tap.clone(),
                 publish_jobs,
+                allow_dirty,
             },
             package_metadata,
             workspace_metadata,


Test patch:
diff --git a/cargo-dist/tests/integration-tests.rs b/cargo-dist/tests/integration-tests.rs
index acbcd5c0..9fd32d53 100644
--- a/cargo-dist/tests/integration-tests.rs
+++ b/cargo-dist/tests/integration-tests.rs
@@ -39,6 +39,7 @@ tap = "axodotdev/homebrew-packages"
 publish-jobs = ["homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
 scope = "@axodotdev"
@@ -70,6 +71,7 @@ installers = ["shell", "powershell", "homebrew", "npm"]
 tap = "axodotdev/homebrew-packages"
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
 scope = "@axodotdev"
@@ -102,6 +104,7 @@ tap = "axodotdev/homebrew-packages"
 publish-jobs = ["homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
 scope = "@axodotdev"
@@ -133,6 +136,7 @@ fn akaikatana_basic() -> Result<(), miette::Report> {
 cargo-dist-version = "{dist_version}"
 rust-toolchain-version = "1.67.1"
 ci = ["github"]
+allow-dirty = ["github"]
 installers = ["shell", "powershell", "homebrew"]
 tap = "mistydemeo/homebrew-formulae"
 publish-jobs = ["homebrew"]
@@ -169,6 +173,7 @@ repository = "https://github.com/mistydemeo/akaikatana-repack.git"
 cargo-dist-version = "{dist_version}"
 rust-toolchain-version = "1.67.1"
 ci = ["github"]
+allow-dirty = ["github"]
 installers = ["shell", "powershell", "homebrew"]
 tap = "mistydemeo/homebrew-formulae"
 publish-jobs = ["homebrew"]
@@ -201,6 +206,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "CARGO_HOME"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -227,6 +233,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "~/.axolotlsay/"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -253,6 +260,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "~/.axolotlsay/bins"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -279,6 +287,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "~/My Axolotlsay Documents"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -304,6 +313,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "~/My Axolotlsay Documents/bin/"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -330,6 +340,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "$MY_ENV_VAR/"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -356,6 +367,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "$MY_ENV_VAR/bin/"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -382,6 +394,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "$MY_ENV_VAR/My Axolotlsay Documents"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -408,6 +421,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "$MY_ENV_VAR/My Axolotlsay Documents/bin"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -435,6 +449,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "~/"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
@@ -460,6 +475,7 @@ cargo-dist-version = "{dist_version}"
 installers = ["shell", "powershell", "homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
 ci = ["github"]
+allow-dirty = ["github"]
 install-path = "$MY_ENV"
 unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
diff --git a/cargo-dist/tests/snapshots/akaikatana_basic.snap b/cargo-dist/tests/snapshots/akaikatana_basic.snap
index 6d0cd702..07949ad6 100644
--- a/cargo-dist/tests/snapshots/akaikatana_basic.snap
+++ b/cargo-dist/tests/snapshots/akaikatana_basic.snap
@@ -1262,7 +1262,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 6d0cd702..07949ad6 100644
--- a/cargo-dist/tests/snapshots/akaikatana_repo_with_dot_git.snap
+++ b/cargo-dist/tests/snapshots/akaikatana_repo_with_dot_git.snap
@@ -1262,7 +1262,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/axolotlsay_basic.snap b/cargo-dist/tests/snapshots/axolotlsay_basic.snap
index 779c1b15..f2f144a6 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_basic.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_basic.snap
@@ -2135,7 +2135,8 @@ maybeInstall(true).then(run);
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap b/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
index b2cf341c..b89ea96c 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
@@ -2135,7 +2135,8 @@ maybeInstall(true).then(run);
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap b/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
index 0376305e..5e8b2cc6 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
@@ -2135,7 +2135,8 @@ maybeInstall(true).then(run);
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/install_path_cargo_home.snap b/cargo-dist/tests/snapshots/install_path_cargo_home.snap
index e00e5439..72264e7c 100644
--- a/cargo-dist/tests/snapshots/install_path_cargo_home.snap
+++ b/cargo-dist/tests/snapshots/install_path_cargo_home.snap
@@ -1264,7 +1264,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 ef12f6e1..e24b311a 100644
--- a/cargo-dist/tests/snapshots/install_path_env_no_subdir.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_no_subdir.snap
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/install_path_env_subdir.snap b/cargo-dist/tests/snapshots/install_path_env_subdir.snap
index 221c186b..102a7045 100644
--- a/cargo-dist/tests/snapshots/install_path_env_subdir.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_subdir.snap
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 faaa8a25..c34a220c 100644
--- a/cargo-dist/tests/snapshots/install_path_env_subdir_space.snap
+++ b/cargo-dist/tests/snapshots/install_path_env_subdir_space.snap
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 f57b07ff..9a4cc8cd 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
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 9caf1eeb..2788db83 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_deeper.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_deeper.snap
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 9e513b06..57e74296 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_min.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_min.snap
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 a411f0ea..e50cbc61 100644
--- a/cargo-dist/tests/snapshots/install_path_home_subdir_space.snap
+++ b/cargo-dist/tests/snapshots/install_path_home_subdir_space.snap
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
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 754bea19..fd45d536 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
@@ -1244,7 +1244,8 @@ Install-Binary "$Args"
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": true
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/lib_manifest.snap b/cargo-dist/tests/snapshots/lib_manifest.snap
index f8964522..55870125 100644
--- a/cargo-dist/tests/snapshots/lib_manifest.snap
+++ b/cargo-dist/tests/snapshots/lib_manifest.snap
@@ -15,7 +15,8 @@ stdout:
   "ci": {
     "github": {
       "artifacts_matrix": {},
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": false
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/lib_manifest_slash.snap b/cargo-dist/tests/snapshots/lib_manifest_slash.snap
index 91934bef..1c6687cd 100644
--- a/cargo-dist/tests/snapshots/lib_manifest_slash.snap
+++ b/cargo-dist/tests/snapshots/lib_manifest_slash.snap
@@ -15,7 +15,8 @@ stdout:
   "ci": {
     "github": {
       "artifacts_matrix": {},
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": false
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/long-help.snap b/cargo-dist/tests/snapshots/long-help.snap
index 9c354474..32774f9a 100644
--- a/cargo-dist/tests/snapshots/long-help.snap
+++ b/cargo-dist/tests/snapshots/long-help.snap
@@ -13,7 +13,8 @@ Usage: cargo dist [OPTIONS]
 Commands:
   build        Build artifacts
   init         Setup or update cargo-dist
-  generate-ci  Generate CI scripts for orchestrating cargo-dist
+  generate     Generate one or more pieces of configuration
+  generate-ci  Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate)
   manifest     Generate the final build manifest without running any builds
   plan         Get a plan of what to build (and check project status)
   help         Print this message or the help of the given subcommand(s)
diff --git a/cargo-dist/tests/snapshots/manifest.snap b/cargo-dist/tests/snapshots/manifest.snap
index 84e9153b..5900b506 100644
--- a/cargo-dist/tests/snapshots/manifest.snap
+++ b/cargo-dist/tests/snapshots/manifest.snap
@@ -258,7 +258,8 @@ stdout:
           }
         ]
       },
-      "pr_run_mode": "upload"
+      "pr_run_mode": "upload",
+      "allow_dirty": false
     }
   }
 }
diff --git a/cargo-dist/tests/snapshots/markdown-help.snap b/cargo-dist/tests/snapshots/markdown-help.snap
index cc624fca..a7de0b3c 100644
--- a/cargo-dist/tests/snapshots/markdown-help.snap
+++ b/cargo-dist/tests/snapshots/markdown-help.snap
@@ -21,7 +21,8 @@ cargo dist <COMMAND>
 ### Commands
 * [build](#cargo-dist-build): Build artifacts
 * [init](#cargo-dist-init): Setup or update cargo-dist
-* [generate-ci](#cargo-dist-generate-ci): Generate CI scripts for orchestrating cargo-dist
+* [generate](#cargo-dist-generate): Generate one or more pieces of configuration
+* [generate-ci](#cargo-dist-generate-ci): Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate)
 * [manifest](#cargo-dist-manifest): Generate the final build manifest without running any builds
 * [plan](#cargo-dist-plan): Get a plan of what to build (and check project status)
 * [help](#cargo-dist-help): Print this message or the help of the given subcommand(s)
@@ -156,9 +157,36 @@ Print help (see a summary with '-h')
 ### GLOBAL OPTIONS
 This subcommand accepts all the [global options](#global-options)
 
+<br><br><br>
+## cargo dist generate
+Generate one or more pieces of configuration
+
+### Usage
+
+```text
+cargo dist generate [OPTIONS] [MODE]...
+```
+
+### Arguments
+\[MODE]...  
+Which type of configuration to generate
+
+Possible values:
+- ci: Generate CI scripts for orchestrating cargo-dist
+
+### Options
+#### `--check`
+Check if the generated output differs from on-disk config without writing it
+
+#### `-h, --help`
+Print help (see a summary with '-h')
+
+### GLOBAL OPTIONS
+This subcommand accepts all the [global options](#global-options)
+
 <br><br><br>
 ## cargo dist generate-ci
-Generate CI scripts for orchestrating cargo-dist
+Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate)
 
 ### Usage
 
@@ -167,6 +195,9 @@ cargo dist generate-ci [OPTIONS]
 ```
 
 ### Options
+#### `--check`
+Check if the generated output differs from on-disk config without writing it
+
 #### `-h, --help`
 Print help (see a summary with '-h')
 
@@ -251,7 +282,8 @@ cargo dist help [COMMAND]
 ### Commands
 * [build](#cargo-dist-build): Build artifacts
 * [init](#cargo-dist-init): Setup or update cargo-dist
-* [generate-ci](#cargo-dist-generate-ci): Generate CI scripts for orchestrating cargo-dist
+* [generate](#cargo-dist-generate): Generate one or more pieces of configuration
+* [generate-ci](#cargo-dist-generate-ci): Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate)
 * [manifest](#cargo-dist-manifest): Generate the final build manifest without running any builds
 * [plan](#cargo-dist-plan): Get a plan of what to build (and check project status)
 * [help](#cargo-dist-help): Print this message or the help of the given subcommand(s)
diff --git a/cargo-dist/tests/snapshots/short-help.snap b/cargo-dist/tests/snapshots/short-help.snap
index f9a81be4..710743c4 100644
--- a/cargo-dist/tests/snapshots/short-help.snap
+++ b/cargo-dist/tests/snapshots/short-help.snap
@@ -11,7 +11,8 @@ Usage: cargo dist [OPTIONS]
 Commands:
   build        Build artifacts
   init         Setup or update cargo-dist
-  generate-ci  Generate CI scripts for orchestrating cargo-dist
+  generate     Generate one or more pieces of configuration
+  generate-ci  Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate)
   manifest     Generate the final build manifest without running any builds
   plan         Get a plan of what to build (and check project status)
   help         Print this message or the help of the given subcommand(s)