diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab8a32bd2c..2f2b1f5dbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,8 +33,9 @@ jobs: run: | # Tolerate a leading "v" (e.g. v1.0.0) so the tag isn't doubled into "vv1.0.0". VERSION="${VERSION#v}" - # Validate via the shared script (same semver check as the auto-updater). - go run ./cmd/scripts/semver "$VERSION" + # Validate via the shared script (same semver check as the auto-updater) and + # ensure it matches lib.SoftwareVersion. + go run ./cmd/scripts/semver --check-server-version "$VERSION" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "Version '$VERSION' is valid. Release tag: v${VERSION}" diff --git a/cmd/auto-update/main.go b/cmd/auto-update/main.go index 46235bd60c..e0f5eed5dd 100644 --- a/cmd/auto-update/main.go +++ b/cmd/auto-update/main.go @@ -11,7 +11,6 @@ import ( "time" "github.com/canopy-network/canopy/cmd/cli" - "github.com/canopy-network/canopy/cmd/rpc" "github.com/canopy-network/canopy/lib" ) @@ -109,9 +108,9 @@ func main() { binaryVersion := getSoftwareVersion(autoUpdaterEnabled, binPath) if autoUpdaterEnabled { logger.Infof("auto-update enabled (updater version: %s, canopy version: %s)", - rpc.SoftwareVersion, binaryVersion) + lib.SoftwareVersion, binaryVersion) } else { - logger.Infof("auto-update disabled (canopy version: %s)", rpc.SoftwareVersion) + logger.Infof("auto-update disabled (canopy version: %s)", lib.SoftwareVersion) } // handle external shutdown signals sigChan := make(chan os.Signal, 1) @@ -275,7 +274,7 @@ func effectiveBinPath(paths ...string) string { func getSoftwareVersion(autoUpdate bool, binPath string) string { if !autoUpdate { - return rpc.SoftwareVersion + return lib.SoftwareVersion } // forward only --data-dir so the check targets the initialized directory and // avoids passing newer flags an older installed binary may not recognize diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 8ceb889f22..67d4e79a54 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -57,7 +57,7 @@ var versionCmd = &cobra.Command{ return nil }, Run: func(cmd *cobra.Command, args []string) { - fmt.Println(rpc.SoftwareVersion) + fmt.Println(lib.SoftwareVersion) }, } @@ -175,7 +175,7 @@ func Start() { l.Fatal(err.Error()) } // initialize and start the metrics server - metrics := lib.NewMetricsServer(validatorKey.PublicKey().Address(), float64(config.ChainId), rpc.SoftwareVersion, config.MetricsConfig, l) + metrics := lib.NewMetricsServer(validatorKey.PublicKey().Address(), float64(config.ChainId), lib.SoftwareVersion, config.MetricsConfig, l) // create a new database object from the config db, err := store.New(config, metrics, l) if err != nil { diff --git a/cmd/rpc/query.go b/cmd/rpc/query.go index bc5ca14605..d9e186ca1b 100644 --- a/cmd/rpc/query.go +++ b/cmd/rpc/query.go @@ -19,7 +19,7 @@ import ( // Version writes Canopy software's version information func (s *Server) Version(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - write(w, SoftwareVersion, http.StatusOK) + write(w, lib.SoftwareVersion, http.StatusOK) } // Transaction submits a transaction diff --git a/cmd/rpc/server.go b/cmd/rpc/server.go index 8df888d27b..587b3740ff 100644 --- a/cmd/rpc/server.go +++ b/cmd/rpc/server.go @@ -31,9 +31,6 @@ import ( const ( colon = ":" - // uses golang's semver naming convention - // https://pkg.go.dev/golang.org/x/mod/semver - SoftwareVersion = "v0.1.31+beta" ContentType = "Content-MessageType" ApplicationJSON = "application/json; charset=utf-8" diff --git a/cmd/scripts/semver/main.go b/cmd/scripts/semver/main.go index 5f35d56ec5..a03a762026 100644 --- a/cmd/scripts/semver/main.go +++ b/cmd/scripts/semver/main.go @@ -1,10 +1,16 @@ // Command semver validates that its argument is valid semver via // lib.IsValidVersion (the same check the auto-updater uses), so the release // GitHub Actions fail fast on malformed versions before a release is created. -// Usage: semver (a leading "v" is optional; exit 0 means valid). +// +// Usage: semver [--check-server-version] (a leading "v" is optional; +// exit 0 means valid). When --check-server-version is set (used by the main +// release workflow, not the plugin releases), it additionally asserts that +// matches lib.SoftwareVersion, ignoring only the optional "v" prefix +// (build metadata such as "+beta" must match). package main import ( + "flag" "fmt" "os" @@ -18,13 +24,24 @@ func main() { } } -// run validates the single version argument. +// run validates the single version argument, optionally cross-checking it +// against lib.SoftwareVersion. func run(args []string) error { - if len(args) != 1 || args[0] == "" { - return fmt.Errorf("usage: semver ") + fs := flag.NewFlagSet("semver", flag.ContinueOnError) + checkServerVersion := fs.Bool("check-server-version", false, + "also require the version to match lib.SoftwareVersion") + if err := fs.Parse(args); err != nil { + return err } - if !lib.IsValidVersion(args[0]) { - return fmt.Errorf("invalid semantic version: %q (expected MAJOR.MINOR.PATCH, e.g. 1.2.3 or v1.2.3)", args[0]) + if fs.NArg() != 1 || fs.Arg(0) == "" { + return fmt.Errorf("usage: semver [--check-server-version] ") + } + version := fs.Arg(0) + if !lib.IsValidVersion(version) { + return fmt.Errorf("invalid semantic version: %q (expected MAJOR.MINOR.PATCH, e.g. 1.2.3 or v1.2.3)", version) + } + if *checkServerVersion && !lib.MatchesSoftwareVersion(version) { + return fmt.Errorf("version mismatch: remember to update the version number in lib/version.go") } return nil } diff --git a/cmd/scripts/semver/main_test.go b/cmd/scripts/semver/main_test.go index 3d4e06a15f..2dd98cb40b 100644 --- a/cmd/scripts/semver/main_test.go +++ b/cmd/scripts/semver/main_test.go @@ -1,8 +1,10 @@ package main import ( + "strings" "testing" + "github.com/canopy-network/canopy/lib" "github.com/stretchr/testify/require" ) @@ -27,3 +29,27 @@ func TestRun(t *testing.T) { require.Error(t, run([]string{"1.0.0", "2.0.0"})) }) } + +func TestRunCheckServerVersion(t *testing.T) { + // Strip only the optional "v"; build metadata like "+beta" is significant. + version := strings.TrimPrefix(lib.SoftwareVersion, "v") + t.Run("matching bare version", func(t *testing.T) { + require.NoError(t, run([]string{"--check-server-version", version})) + }) + t.Run("matching v-prefixed version", func(t *testing.T) { + require.NoError(t, run([]string{"--check-server-version", "v" + version})) + }) + t.Run("mismatched version", func(t *testing.T) { + require.Error(t, run([]string{"--check-server-version", "99.99.99"})) + }) + t.Run("mismatched build metadata", func(t *testing.T) { + i := strings.IndexByte(version, '+') + if i < 0 { + t.Skip("server version has no build metadata to differ on") + } + require.Error(t, run([]string{"--check-server-version", version[:i]})) + }) + t.Run("skips check when flag absent", func(t *testing.T) { + require.NoError(t, run([]string{"99.99.99"})) + }) +} diff --git a/lib/util.go b/lib/util.go index c91c53139a..dc4ca26ecc 100644 --- a/lib/util.go +++ b/lib/util.go @@ -21,7 +21,6 @@ import ( "unsafe" "github.com/canopy-network/canopy/lib/crypto" - "golang.org/x/mod/semver" "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -1100,17 +1099,3 @@ func RandSlice(byteSize uint64) []byte { rand.Read(value) return value } - -// IsValidVersion reports whether version is valid semver, accepting an optional -// leading "v". Shared so the release script and auto-updater validate alike. -func IsValidVersion(version string) bool { - return semver.IsValid(normalizeVersion(version)) -} - -// normalizeVersion adds the leading "v" that golang.org/x/mod/semver requires. -func normalizeVersion(version string) string { - if version == "" || version[0] == 'v' { - return version - } - return "v" + version -} diff --git a/lib/util_test.go b/lib/util_test.go index 99a040be4f..2e52db37d5 100644 --- a/lib/util_test.go +++ b/lib/util_test.go @@ -637,46 +637,3 @@ func TestLoadCountedCallbackError(t *testing.T) { page := NewPage(PageParams{PageNumber: 1, PerPage: 10}, TxResultsPageName) require.Error(t, page.LoadCounted(25, &results, func(index int) ErrorI { return ErrInvalidArgument() })) } - -func TestIsValidVersion(t *testing.T) { - tests := []struct { - name string - input string - want bool - }{ - // valid, bare (as entered in the manual release flow) - {"bare patch", "1.0.0", true}, - {"bare large", "10.20.30", true}, - {"bare prerelease", "1.0.0-rc.1", true}, - {"bare prerelease zero", "1.2.3-0", true}, - {"bare prerelease alnum", "1.2.3-alpha.1", true}, - // valid, v-prefixed (as passed by old callers / full tags) - {"v patch", "v1.0.0", true}, - {"v build metadata", "v1.2.3+build.5", true}, - // semver.IsValid accepts partial versions; documented here so the - // behavior is intentional and does not surprise future readers. - {"v major only", "v1", true}, - {"v major minor", "v1.2", true}, - // invalid: the original bug that motivated this check - {"four segments bare", "1.0.4.5", false}, - {"four segments v", "v1.0.4.5", false}, - // invalid prerelease/build forms rejected by x/mod/semver - {"leading zero prerelease", "1.2.3-01", false}, - {"empty prerelease identifiers", "1.2.3-..", false}, - {"trailing dot prerelease", "1.2.3-alpha.", false}, - // invalid: not versions at all - {"garbage", "foo", false}, - {"empty", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, IsValidVersion(tt.input)) - }) - } -} - -func TestNormalizeVersion(t *testing.T) { - require.Equal(t, "v1.2.3", normalizeVersion("1.2.3")) - require.Equal(t, "v1.2.3", normalizeVersion("v1.2.3")) - require.Equal(t, "", normalizeVersion("")) -} diff --git a/lib/version.go b/lib/version.go new file mode 100644 index 0000000000..d0eb4776f2 --- /dev/null +++ b/lib/version.go @@ -0,0 +1,32 @@ +package lib + +import ( + "strings" + + "golang.org/x/mod/semver" +) + +// SoftwareVersion is the running Canopy software version. It follows golang's +// semver naming convention (https://pkg.go.dev/golang.org/x/mod/semver). +const SoftwareVersion = "v0.1.31+beta" + +// IsValidVersion reports whether version is valid semver, accepting an optional +// leading "v". Shared so the release script and auto-updater validate alike. +func IsValidVersion(version string) bool { + return semver.IsValid(normalizeVersion(version)) +} + +// MatchesSoftwareVersion reports whether version refers to the running +// SoftwareVersion, accepting an optional leading "v" on either. Build metadata +// (e.g. "+beta") is significant. +func MatchesSoftwareVersion(version string) bool { + return strings.TrimPrefix(version, "v") == strings.TrimPrefix(SoftwareVersion, "v") +} + +// normalizeVersion adds the leading "v" that golang.org/x/mod/semver requires. +func normalizeVersion(version string) string { + if version == "" || version[0] == 'v' { + return version + } + return "v" + version +} diff --git a/lib/version_test.go b/lib/version_test.go new file mode 100644 index 0000000000..c2aa47b3d1 --- /dev/null +++ b/lib/version_test.go @@ -0,0 +1,62 @@ +package lib + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsValidVersion(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // valid, bare (as entered in the manual release flow) + {"bare patch", "1.0.0", true}, + {"bare large", "10.20.30", true}, + {"bare prerelease", "1.0.0-rc.1", true}, + {"bare prerelease zero", "1.2.3-0", true}, + {"bare prerelease alnum", "1.2.3-alpha.1", true}, + // valid, v-prefixed (as passed by old callers / full tags) + {"v patch", "v1.0.0", true}, + {"v build metadata", "v1.2.3+build.5", true}, + // semver.IsValid accepts partial versions; documented here so the + // behavior is intentional and does not surprise future readers. + {"v major only", "v1", true}, + {"v major minor", "v1.2", true}, + // invalid: the original bug that motivated this check + {"four segments bare", "1.0.4.5", false}, + {"four segments v", "v1.0.4.5", false}, + // invalid prerelease/build forms rejected by x/mod/semver + {"leading zero prerelease", "1.2.3-01", false}, + {"empty prerelease identifiers", "1.2.3-..", false}, + {"trailing dot prerelease", "1.2.3-alpha.", false}, + // invalid: not versions at all + {"garbage", "foo", false}, + {"empty", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsValidVersion(tt.input)) + }) + } +} + +func TestMatchesSoftwareVersion(t *testing.T) { + // Strip only the optional "v"; build metadata like "+beta" is significant. + version := strings.TrimPrefix(SoftwareVersion, "v") + require.True(t, MatchesSoftwareVersion(version)) + require.True(t, MatchesSoftwareVersion("v"+version)) + require.False(t, MatchesSoftwareVersion("99.99.99")) + if i := strings.IndexByte(version, '+'); i >= 0 { + require.False(t, MatchesSoftwareVersion(version[:i])) + } +} + +func TestNormalizeVersion(t *testing.T) { + require.Equal(t, "v1.2.3", normalizeVersion("1.2.3")) + require.Equal(t, "v1.2.3", normalizeVersion("v1.2.3")) + require.Equal(t, "", normalizeVersion("")) +}