Repository: knope-dev/knope (rust)

Issue:
Breaking the Go major-version rule
So, previously I've only requested support for using Go modules "correctly"... but now I have a service that is still using `semantic-release` for versioning releases, and switching to Knope would require littering the entire codebase with `v5` because the service is at v5. We don't really want to do that even though it's the "right" way, because this is never used as a library, so the `v5`s just seem like noise.

Would you consider providing a way to opt-out of that rule, permitting Go modules not to contain the major version?

Pull request description:
Allow turning off go.mod major version features
Closes #863 

Patch:
diff --git a/.changeset/add_ignore_go_major_versioning_option.md b/.changeset/add_ignore_go_major_versioning_option.md
new file mode 100644
index 0000000..27060ae
--- /dev/null
+++ b/.changeset/add_ignore_go_major_versioning_option.md
@@ -0,0 +1,12 @@
+---
+default: minor
+---
+
+# Add `ignore_go_major_versioning` option
+
+You can now set `ignore_go_major_versioning = true` for a package in
+`knope.toml` to turn off the major version validation & updating in `go.mod` files.
+
+More details in [the new docs](https://knope.tech/reference/config-file/packages/#ignore_go_major_versioning).
+
+Closes #863, thanks for the suggestion @BatmanAoD!
diff --git a/docs/src/content/docs/recipes/multiple-major-go-versions.md b/docs/src/content/docs/recipes/multiple-major-go-versions.md
index 2cfcd9e..1e2abf6 100644
--- a/docs/src/content/docs/recipes/multiple-major-go-versions.md
+++ b/docs/src/content/docs/recipes/multiple-major-go-versions.md
@@ -2,7 +2,15 @@
 title: Multiple major Go versions
 ---
 
-The [recommended best practice](https://go.dev/blog/v2-go-modules) for maintaining multiple major versions of Go modules is to include every major version on your main branch (rather than separate branches). To support multiple go modules files in Knope, you have to define them as separate packages:
+The [recommended best practice](https://go.dev/blog/v2-go-modules) for maintaining multiple major versions of Go modules is to include every major version on your main branch (rather than separate branches).
+
+:::tip
+
+It's also possible to [ignore Go's major version requirements](/reference/config-file/packages#ignore_go_major_versioning)
+
+:::
+
+To support multiple go modules files in Knope, you have to define them as separate packages:
 
 ```toml
 # knope.toml
diff --git a/docs/src/content/docs/reference/Config File/packages.md b/docs/src/content/docs/reference/Config File/packages.md
index 6bb1f08..8f8e0b1 100644
--- a/docs/src/content/docs/reference/Config File/packages.md
+++ b/docs/src/content/docs/reference/Config File/packages.md
@@ -83,6 +83,9 @@ module github.com/knope-dev/knope // v0.0.1
 module github.com/knope-dev/knope/v2 // v2.0.0
 ```
 
+To omit the major version from the module line (e.g., for binaries, where it doesn't matter much),
+use the [`ignore_go_major_versioning`](#ignore_go_major_versioning) option.
+
 ### `pubspec.yaml`
 
 For Dart projects, must contain a `version` field:
@@ -150,3 +153,24 @@ name = "linux-amd64.tgz"
 [[package.assets]]
 path = "artifact/my-binary-darwin-amd64.tgz"  # name will be "my-binary-darwin-amd64.tgz"
 ```
+
+## `ignore_go_major_versioning`
+
+Go has special rules about major versions above 1. Specifically, the module line in `go.mod` must end in the major version.
+By default, Knope follows these rules,
+so if there is no major version at the end of the module line,
+Knope will assume you're updating the latest 1.x or 0.x tag.
+
+To ignore this rule, and always use the latest tag (even if it doesn't match the module line), set `ignore_go_major_versioning` to `true` in the package config:
+
+```toml title="knope.toml"
+[package]
+versioned_files = ["go.mod"]
+ignore_go_major_versioning = true
+```
+
+:::tip
+
+To maintain multiple major versions of a Go module, check out [this recipe](/recipes/multiple-major-go-versions)
+
+:::
diff --git a/src/config/toml/package.rs b/src/config/toml/package.rs
index 23af29b..75f180b 100644
--- a/src/config/toml/package.rs
+++ b/src/config/toml/package.rs
@@ -1,4 +1,4 @@
-use std::{fmt, fmt::Display, path::PathBuf};
+use std::{fmt, fmt::Display, ops::Not, path::PathBuf};
 
 use git_conventional::FooterToken;
 use miette::Diagnostic;
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 use crate::step::releases::{
-    changelog, changelog::Changelog, package::Asset, versioned_file, versioned_file::VersionedFile,
-    PackageName,
+    changelog, changelog::Changelog, go::GoVersioning, package::Asset, versioned_file,
+    versioned_file::VersionedFile, PackageName,
 };
 
 /// Represents a single package in `knope.toml`.
@@ -24,6 +24,8 @@ pub struct Package {
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub(crate) extra_changelog_sections: Vec<ChangelogSection>,
     assets: Option<Vec<Asset>>,
+    #[serde(default, skip_serializing_if = "<&bool>::not")]
+    ignore_go_major_versioning: bool,
 }
 
 impl TryFrom<(Option<PackageName>, Package)> for crate::step::releases::Package {
@@ -44,6 +46,11 @@ impl TryFrom<(Option<PackageName>, Package)> for crate::step::releases::Package
             prepared_release: None,
             override_version: None,
             assets: package.assets,
+            go_versioning: if package.ignore_go_major_versioning {
+                GoVersioning::IgnoreMajorRules
+            } else {
+                GoVersioning::default()
+            },
         })
     }
 }
@@ -76,6 +83,7 @@ impl From<crate::step::releases::Package> for Package {
             scopes: package.scopes,
             extra_changelog_sections: package.changelog_sections.into(),
             assets: package.assets,
+            ignore_go_major_versioning: package.go_versioning == GoVersioning::IgnoreMajorRules,
         }
     }
 }
diff --git a/src/step/releases/go.rs b/src/step/releases/go.rs
index ca0d655..40899bf 100644
--- a/src/step/releases/go.rs
+++ b/src/step/releases/go.rs
@@ -47,31 +47,50 @@ pub(crate) enum Error {
     ModuleLine(#[from] ModuleLineError),
 }
 
-/// Sets the version in go.mod, but does not create the Git tag which _actually_ is the source
+#[derive(Debug, Default, Eq, Clone, Copy, PartialEq)]
+pub(crate) enum GoVersioning {
+    #[default]
+    Standard,
+    IgnoreMajorRules,
+}
+
+/// Sets the version in `go.mod`, but doesn't create the Git tag which _actually_ is the source
 /// of truth for Go versions. That will be set by [`create_version_tag`] in the [`crate::Step::Release`].
 pub(crate) fn set_version_in_file(
     dry_run: DryRun,
     content: &str,
     new_version: &VersionFromSource,
     path: &Path,
+    versioning: GoVersioning,
 ) -> Result<String, Error> {
     let original_module_line = content
         .lines()
         .find(|line| line.starts_with("module "))
         .ok_or(Error::MissingModuleLine)?;
     let mut module_line = ModuleLine::from_str(original_module_line)?;
-    match (module_line.major_version, path.parent(), new_version.version.stable_component().major, &new_version.source) {
-        (None, _, new_major, _) if new_major == 0 || new_major == 1 => {},  // No change
-        (None, _, _, VersionSource::OverrideVersion) => {},  // Override tells us that they're aware of the risks
-        (None, _, _, _) => return Err(Error::BumpingToV2),  // No major version in go.mod, but we're bumping to >1 without explicit override
-        (Some(module_major), _, new_major, _) if module_major == new_major => {},  // No change
-        (Some(_), None, _, _)  => {} | (Some(old_major), Some(ancestor), _, _) if ancestor.display().to_string() != format!("v{old_major}")  // Allowed to bump >1 to >1 if not using path-based directories
-         => {},
-        (Some(_), _, _, _) => return Err(Error::MajorVersionDirectoryBased),
-    }
-    module_line.major_version = Some(new_version.version.stable_component().major);
     module_line.version = Some(new_version.version.clone());
 
+    let new_major = new_version.version.stable_component().major;
+    let module_line_needs_updating = new_major > 1
+        && new_major != module_line.major_version.unwrap_or(0)
+        && versioning == GoVersioning::Standard;
+
+    if module_line_needs_updating {
+        if module_line.major_version.is_none()
+            && new_version.source != VersionSource::OverrideVersion
+        {
+            return Err(Error::BumpingToV2);
+        }
+        let using_major_version_directories = module_line.major_version.is_some_and(|old_major| {
+            path.parent()
+                .is_some_and(|parent| parent.ends_with(format!("v{old_major}")))
+        });
+        if using_major_version_directories {
+            return Err(Error::MajorVersionDirectoryBased);
+        }
+        module_line.major_version = Some(new_version.version.stable_component().major);
+    }
+
     let new_content = content.replace(original_module_line, &module_line.to_string());
     fs::write(
         dry_run,
diff --git a/src/step/releases/package.rs b/src/step/releases/package.rs
index e80eb15..ba6f347 100644
--- a/src/step/releases/package.rs
+++ b/src/step/releases/package.rs
@@ -8,6 +8,7 @@ use std::{
     path::PathBuf,
 };
 
+use enum_iterator::all;
 use indexmap::IndexMap;
 use itertools::Itertools;
 use miette::Diagnostic;
@@ -20,7 +21,7 @@ use super::{
     semver,
     semver::{bump, ConventionalRule, Label, Version},
     versioned_file,
-    versioned_file::{VersionedFile, PACKAGE_FORMAT_FILE_NAMES},
+    versioned_file::VersionedFile,
     workspace, Change, Release, Rule,
 };
 use crate::{
@@ -31,7 +32,8 @@ use crate::{
     fs,
     integrations::git::{self, add_files},
     step::releases::{
-        versioned_file::{VersionFromSource, VersionSource},
+        go::GoVersioning,
+        versioned_file::{PackageFormat, VersionFromSource, VersionSource},
         workspace::check_for_workspaces,
     },
     workflow::Verbose,
@@ -49,6 +51,7 @@ pub(crate) struct Package {
     /// Version manually set by the caller to use instead of the one determined by semantic rule
     pub(crate) override_version: Option<Version>,
     pub(crate) assets: Option<Vec<Asset>>,
+    pub(crate) go_versioning: GoVersioning,
 }
 
 impl Package {
@@ -402,10 +405,9 @@ pub(crate) fn find_packages() -> Result<Vec<Package>, Error> {
         .then(|| Changelog::try_from(default_changelog_path))
         .transpose()?;
 
-    let versioned_files = PACKAGE_FORMAT_FILE_NAMES
-        .iter()
-        .filter_map(|name| {
-            let path = PathBuf::from(name);
+    let versioned_files = all::<PackageFormat>()
+        .filter_map(|format| {
+            let path = PathBuf::from(format.file_name());
             if path.exists() {
                 Some(VersionedFile::try_from(path))
             } else {
diff --git a/src/step/releases/semver/mod.rs b/src/step/releases/semver/mod.rs
index b35a1f2..b78b0a8 100644
--- a/src/step/releases/semver/mod.rs
+++ b/src/step/releases/semver/mod.rs
@@ -214,7 +214,7 @@ impl Package {
         dry_run: DryRun,
     ) -> Result<Self, versioned_file::Error> {
         for versioned_file in &mut self.versioned_files {
-            versioned_file.set_version(dry_run, version)?;
+            versioned_file.set_version(dry_run, version, self.go_versioning)?;
         }
         Ok(self)
     }
diff --git a/src/step/releases/versioned_file.rs b/src/step/releases/versioned_file.rs
index a8b432b..57e78d3 100644
--- a/src/step/releases/versioned_file.rs
+++ b/src/step/releases/versioned_file.rs
@@ -5,12 +5,12 @@ use std::{
     path::{Path, PathBuf},
 };
 
-use itertools::Itertools;
+use enum_iterator::{all, Sequence};
 use miette::Diagnostic;
 use thiserror::Error;
 
 use super::{cargo, git, go, package_json, pubspec_yaml, pyproject, semver::Version};
-use crate::{dry_run::DryRun, workflow::Verbose};
+use crate::{dry_run::DryRun, step::releases::go::GoVersioning, workflow::Verbose};
 
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub(crate) struct VersionedFile {
@@ -99,15 +99,20 @@ impl VersionedFile {
         &mut self,
         dry_run: DryRun,
         version_str: &VersionFromSource,
+        go_versioning: GoVersioning,
     ) -> Result<()> {
-        self.content =
-            self.format
-                .set_version(dry_run, self.content.clone(), version_str, &self.path)?;
+        self.content = self.format.set_version(
+            dry_run,
+            self.content.clone(),
+            version_str,
+            &self.path,
+            go_versioning,
+        )?;
         Ok(())
     }
 }
 
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Sequence)]
 pub(crate) enum PackageFormat {
     Cargo,
     Dart,
@@ -124,10 +129,8 @@ impl TryFrom<&PathBuf> for PackageFormat {
             .file_name()
             .and_then(OsStr::to_str)
             .ok_or_else(|| ErrorKind::NotAFile(path.clone()))?;
-        PACKAGE_FORMAT_FILE_NAMES
-            .iter()
-            .find_position(|&name| *name == file_name)
-            .and_then(|(pos, _)| ALL_PACKAGE_FORMATS.get(pos).copied())
+        all::<PackageFormat>()
+            .find(|&format| format.file_name() == file_name)
             .ok_or_else(|| Error::from(ErrorKind::VersionedFileFormat(path.clone())))
     }
 }
@@ -160,7 +163,9 @@ impl PackageFormat {
                     version,
                     source: path.into(),
                 }),
-            PackageFormat::Go => go::get_version(content, path, verbose).map_err(ErrorKind::Go),
+            PackageFormat::Go { .. } => {
+                go::get_version(content, path, verbose).map_err(ErrorKind::Go)
+            }
             PackageFormat::Dart => pubspec_yaml::get_version(content, path)
                 .map_err(ErrorKind::PubSpecYaml)
                 .map(|version| VersionFromSource {
@@ -180,6 +185,7 @@ impl PackageFormat {
         content: String,
         new_version: &VersionFromSource,
         path: &Path,
+        go_versioning: GoVersioning,
     ) -> Result<String> {
         match self {
             PackageFormat::Cargo => {
@@ -195,7 +201,8 @@ impl PackageFormat {
                     .map_err(Error::from)
             }
             PackageFormat::Go => {
-                go::set_version_in_file(dry_run, &content, new_version, path).map_err(Error::from)
+                go::set_version_in_file(dry_run, &content, new_version, path, go_versioning)
+                    .map_err(Error::from)
             }
             PackageFormat::Dart => {
                 pubspec_yaml::set_version(dry_run, &content, &new_version.version, path)
@@ -203,6 +210,16 @@ impl PackageFormat {
             }
         }
     }
+
+    pub(crate) const fn file_name(self) -> &'static str {
+        match self {
+            PackageFormat::Cargo => "Cargo.toml",
+            PackageFormat::Dart => "pubspec.yaml",
+            PackageFormat::Go { .. } => "go.mod",
+            PackageFormat::JavaScript => "package.json",
+            PackageFormat::Poetry => "pyproject.toml",
+        }
+    }
 }
 
 /// A version and where it came from.
@@ -243,18 +260,3 @@ impl From<&Path> for VersionSource {
         Self::File(path.display().to_string())
     }
 }
-
-const ALL_PACKAGE_FORMATS: [PackageFormat; 5] = [
-    PackageFormat::Cargo,
-    PackageFormat::Dart,
-    PackageFormat::Go,
-    PackageFormat::JavaScript,
-    PackageFormat::Poetry,
-];
-pub(crate) const PACKAGE_FORMAT_FILE_NAMES: [&str; ALL_PACKAGE_FORMATS.len()] = [
-    "Cargo.toml",
-    "pubspec.yaml",
-    "go.mod",
-    "package.json",
-    "pyproject.toml",
-];


Test patch:
diff --git a/tests/prepare_release/go_modules/ignore_go_major_versioning/dry_run_output.txt b/tests/prepare_release/go_modules/ignore_go_major_versioning/dry_run_output.txt
new file mode 100644
index 0000000..8cf43c9
--- /dev/null
+++ b/tests/prepare_release/go_modules/ignore_go_major_versioning/dry_run_output.txt
@@ -0,0 +1,4 @@
+Would add the following to go.mod: 2.0.1
+Would add files to git:
+  go.mod
+Would create Git tag v2.0.1
diff --git a/tests/prepare_release/go_modules/ignore_go_major_versioning/expected/go.mod b/tests/prepare_release/go_modules/ignore_go_major_versioning/expected/go.mod
new file mode 100644
index 0000000..e3b4eac
--- /dev/null
+++ b/tests/prepare_release/go_modules/ignore_go_major_versioning/expected/go.mod
@@ -0,0 +1,1 @@
+module something/test // v2.0.1
\ No newline at end of file
diff --git a/tests/prepare_release/go_modules/ignore_go_major_versioning/expected/knope.toml b/tests/prepare_release/go_modules/ignore_go_major_versioning/expected/knope.toml
new file mode 100644
index 0000000..63e211f
--- /dev/null
+++ b/tests/prepare_release/go_modules/ignore_go_major_versioning/expected/knope.toml
@@ -0,0 +1,12 @@
+[package]
+versioned_files = ["go.mod"]
+ignore_go_major_versioning = true
+
+[[workflows]]
+name = "release"
+
+[[workflows.steps]]
+type = "PrepareRelease"
+
+[[workflows.steps]]
+type = "Release"
diff --git a/tests/prepare_release/go_modules/ignore_go_major_versioning/source/go.mod b/tests/prepare_release/go_modules/ignore_go_major_versioning/source/go.mod
new file mode 100644
index 0000000..93ed1a6
--- /dev/null
+++ b/tests/prepare_release/go_modules/ignore_go_major_versioning/source/go.mod
@@ -0,0 +1,1 @@
+module something/test
\ No newline at end of file
diff --git a/tests/prepare_release/go_modules/ignore_go_major_versioning/source/knope.toml b/tests/prepare_release/go_modules/ignore_go_major_versioning/source/knope.toml
new file mode 100644
index 0000000..63e211f
--- /dev/null
+++ b/tests/prepare_release/go_modules/ignore_go_major_versioning/source/knope.toml
@@ -0,0 +1,12 @@
+[package]
+versioned_files = ["go.mod"]
+ignore_go_major_versioning = true
+
+[[workflows]]
+name = "release"
+
+[[workflows.steps]]
+type = "PrepareRelease"
+
+[[workflows.steps]]
+type = "Release"
diff --git a/tests/prepare_release_go_modules.rs b/tests/prepare_release_go_modules.rs
index a88923c..d43d5a5 100644
--- a/tests/prepare_release_go_modules.rs
+++ b/tests/prepare_release_go_modules.rs
@@ -227,3 +227,42 @@ fn major_version_directories() {
         ]
     );
 }
+
+#[test]
+fn ignore_go_major_versioning() {
+    // Arrange.
+    let temp_dir = tempfile::tempdir().unwrap();
+    let temp_path = temp_dir.path();
+    let data_path = Path::new("tests/prepare_release/go_modules/ignore_go_major_versioning");
+    let source_path = data_path.join("source");
+
+    init(temp_path);
+    commit(temp_path, "Initial commit");
+    tag(temp_path, "v1.0.0");
+    commit(temp_path, "fix!: Breaking change");
+    tag(temp_path, "v2.0.0");
+    commit(temp_path, "fix: A fix");
+
+    copy_dir_contents(&source_path, temp_path);
+
+    // Act
+    let dry_run_assert = Command::new(cargo_bin!("knope"))
+        .arg("release")
+        .arg("--dry-run")
+        .current_dir(temp_dir.path())
+        .assert();
+    let actual_assert = Command::new(cargo_bin!("knope"))
+        .arg("release")
+        .current_dir(temp_dir.path())
+        .assert();
+
+    // Assert
+    dry_run_assert
+        .success()
+        .with_assert(assert())
+        .stdout_matches(Data::read_from(&data_path.join("dry_run_output.txt"), None));
+    actual_assert.success().stdout_eq("");
+    assert().subset_matches(data_path.join("expected"), temp_path);
+    let tags = get_tags(temp_path);
+    assert_eq!(tags, vec!["v2.0.1"]);
+}