From eaafdf3623a1dcbf5d58d653d46c0b2a852f5765 Mon Sep 17 00:00:00 2001
From: Ludovic Fernandez <ldez@users.noreply.github.com>
Date: Tue, 19 Mar 2024 21:35:21 +0100
Subject: [PATCH] feat: add verify command (#4527)

---
 .github/workflows/pr.yml                 |   1 +
 .golangci.yml                            |   2 +
 Makefile                                 |   2 +-
 go.mod                                   |   2 +-
 go.sum                                   |   4 +-
 pkg/commands/config.go                   |  51 +++++--
 pkg/commands/config_verify.go            | 176 +++++++++++++++++++++++
 pkg/commands/config_verify_test.go       | 140 ++++++++++++++++++
 pkg/commands/root.go                     |   2 +-
 scripts/website/copy_jsonschema/main.go  | 100 +++++++++++++
 scripts/website/expand_templates/main.go |  46 +-----
 scripts/website/github/github.go         |  49 +++++++
 12 files changed, 518 insertions(+), 57 deletions(-)
 create mode 100644 pkg/commands/config_verify.go
 create mode 100644 pkg/commands/config_verify_test.go
 create mode 100644 scripts/website/copy_jsonschema/main.go
 create mode 100644 scripts/website/github/github.go

diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 84f2fb62..6bf17f1c 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -166,6 +166,7 @@ jobs:
 
       - run: ./golangci-lint config
       - run: ./golangci-lint config path
+      - run: ./golangci-lint config verify --schema jsonschema/golangci.jsonschema.json
 
       - run: ./golangci-lint help
       - run: ./golangci-lint help linters
diff --git a/.golangci.yml b/.golangci.yml
index a0146f2c..d820c370 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -163,6 +163,8 @@ issues:
       text: "SA1019: c.cfg.Run.ShowStats is deprecated: use Output.ShowStats instead."
     - path: pkg/golinters/govet.go
       text: "SA1019: cfg.CheckShadowing is deprecated: the linter should be enabled inside `Enable`."
+    - path: pkg/commands/config.go
+      text: "SA1019: cfg.Run.UseDefaultSkipDirs is deprecated: use Issues.UseDefaultExcludeDirs instead."
 
     - path: pkg/golinters
       linters:
diff --git a/Makefile b/Makefile
index 823bf823..9d14ab5c 100644
--- a/Makefile
+++ b/Makefile
@@ -89,7 +89,7 @@ go.mod: FORCE
 go.sum: go.mod
 
 website_copy_jsonschema:
-	 cp -r ./jsonschema ./docs/static
+	 go run ./scripts/website/copy_jsonschema/
 .PHONY: website_copy_jsonschema
 
 website_expand_templates:
diff --git a/go.mod b/go.mod
index de6c898e..ce77869f 100644
--- a/go.mod
+++ b/go.mod
@@ -80,6 +80,7 @@ require (
 	github.com/nishanths/exhaustive v0.12.0
 	github.com/nishanths/predeclared v0.2.2
 	github.com/nunnatsa/ginkgolinter v0.16.1
+	github.com/pelletier/go-toml/v2 v2.1.1
 	github.com/polyfloyd/go-errorlint v1.4.8
 	github.com/quasilyte/go-ruleguard/dsl v0.3.22
 	github.com/ryancurrah/gomodguard v1.3.1
@@ -161,7 +162,6 @@ require (
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 	github.com/pelletier/go-toml v1.9.5 // indirect
-	github.com/pelletier/go-toml/v2 v2.0.5 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
 	github.com/prometheus/client_golang v1.12.1 // indirect
diff --git a/go.sum b/go.sum
index a55996b5..28902b81 100644
--- a/go.sum
+++ b/go.sum
@@ -410,8 +410,8 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9
 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
 github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
-github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
+github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
+github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
diff --git a/pkg/commands/config.go b/pkg/commands/config.go
index 3d74c89d..cfb7d67a 100644
--- a/pkg/commands/config.go
+++ b/pkg/commands/config.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/fatih/color"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 
@@ -17,13 +18,19 @@ type configCommand struct {
 	viper *viper.Viper
 	cmd   *cobra.Command
 
+	opts       config.LoaderOptions
+	verifyOpts verifyOptions
+
+	buildInfo BuildInfo
+
 	log logutils.Log
 }
 
-func newConfigCommand(log logutils.Log) *configCommand {
+func newConfigCommand(log logutils.Log, info BuildInfo) *configCommand {
 	c := &configCommand{
-		viper: viper.New(),
-		log:   log,
+		viper:     viper.New(),
+		log:       log,
+		buildInfo: info,
 	}
 
 	configCmd := &cobra.Command{
@@ -33,6 +40,15 @@ func newConfigCommand(log logutils.Log) *configCommand {
 		RunE: func(cmd *cobra.Command, _ []string) error {
 			return cmd.Help()
 		},
+		PersistentPreRunE: c.preRunE,
+	}
+
+	verifyCommand := &cobra.Command{
+		Use:               "verify",
+		Short:             "Verify configuration against JSON schema",
+		Args:              cobra.NoArgs,
+		ValidArgsFunction: cobra.NoFileCompletions,
+		RunE:              c.executeVerify,
 	}
 
 	configCmd.AddCommand(
@@ -41,11 +57,21 @@ func newConfigCommand(log logutils.Log) *configCommand {
 			Short:             "Print used config path",
 			Args:              cobra.NoArgs,
 			ValidArgsFunction: cobra.NoFileCompletions,
-			Run:               c.execute,
-			PreRunE:           c.preRunE,
+			Run:               c.executePath,
 		},
+		verifyCommand,
 	)
 
+	flagSet := configCmd.PersistentFlags()
+	flagSet.SortFlags = false // sort them as they are defined here
+
+	setupConfigFileFlagSet(flagSet, &c.opts)
+
+	// ex: --schema jsonschema/golangci.next.jsonschema.json
+	verifyFlagSet := verifyCommand.Flags()
+	verifyFlagSet.StringVar(&c.verifyOpts.schemaURL, "schema", "", color.GreenString("JSON schema URL"))
+	_ = verifyFlagSet.MarkHidden("schema")
+
 	c.cmd = configCmd
 
 	return c
@@ -54,7 +80,16 @@ func newConfigCommand(log logutils.Log) *configCommand {
 func (c *configCommand) preRunE(cmd *cobra.Command, _ []string) error {
 	// The command doesn't depend on the real configuration.
 	// It only needs to know the path of the configuration file.
-	loader := config.NewLoader(c.log.Child(logutils.DebugKeyConfigReader), c.viper, cmd.Flags(), config.LoaderOptions{}, config.NewDefault())
+	cfg := config.NewDefault()
+
+	// Hack to hide deprecation messages related to `--skip-dirs-use-default`:
+	// Flags are not bound then the default values, defined only through flags, are not applied.
+	// In this command, file path and file information are the only requirements, i.e. it don't need flag values.
+	//
+	// TODO(ldez) add an option (check deprecation) to `Loader.Load()` but this require a dedicated PR.
+	cfg.Run.UseDefaultSkipDirs = true
+
+	loader := config.NewLoader(c.log.Child(logutils.DebugKeyConfigReader), c.viper, cmd.Flags(), c.opts, cfg)
 
 	if err := loader.Load(); err != nil {
 		return fmt.Errorf("can't load config: %w", err)
@@ -63,14 +98,14 @@ func (c *configCommand) preRunE(cmd *cobra.Command, _ []string) error {
 	return nil
 }
 
-func (c *configCommand) execute(_ *cobra.Command, _ []string) {
+func (c *configCommand) executePath(cmd *cobra.Command, _ []string) {
 	usedConfigFile := c.getUsedConfig()
 	if usedConfigFile == "" {
 		c.log.Warnf("No config file detected")
 		os.Exit(exitcodes.NoConfigFileDetected)
 	}
 
-	fmt.Println(usedConfigFile)
+	cmd.Println(usedConfigFile)
 }
 
 // getUsedConfig returns the resolved path to the golangci config file,
diff --git a/pkg/commands/config_verify.go b/pkg/commands/config_verify.go
new file mode 100644
index 00000000..291c99a0
--- /dev/null
+++ b/pkg/commands/config_verify.go
@@ -0,0 +1,176 @@
+package commands
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	hcversion "github.com/hashicorp/go-version"
+	"github.com/pelletier/go-toml/v2"
+	"github.com/santhosh-tekuri/jsonschema/v5"
+	_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+	"gopkg.in/yaml.v3"
+
+	"github.com/golangci/golangci-lint/pkg/exitcodes"
+)
+
+type verifyOptions struct {
+	schemaURL string // For debugging purpose only (Flag only).
+}
+
+func (c *configCommand) executeVerify(cmd *cobra.Command, _ []string) error {
+	usedConfigFile := c.getUsedConfig()
+	if usedConfigFile == "" {
+		c.log.Warnf("No config file detected")
+		os.Exit(exitcodes.NoConfigFileDetected)
+	}
+
+	schemaURL, err := createSchemaURL(cmd.Flags(), c.buildInfo)
+	if err != nil {
+		return fmt.Errorf("get JSON schema: %w", err)
+	}
+
+	err = validateConfiguration(schemaURL, usedConfigFile)
+	if err != nil {
+		var v *jsonschema.ValidationError
+		if !errors.As(err, &v) {
+			return fmt.Errorf("[%s] validate: %w", usedConfigFile, err)
+		}
+
+		detail := v.DetailedOutput()
+
+		printValidationDetail(cmd, &detail)
+
+		return fmt.Errorf("the configuration contains invalid elements")
+	}
+
+	return nil
+}
+
+func createSchemaURL(flags *pflag.FlagSet, buildInfo BuildInfo) (string, error) {
+	schemaURL, err := flags.GetString("schema")
+	if err != nil {
+		return "", fmt.Errorf("get schema flag: %w", err)
+	}
+
+	if schemaURL != "" {
+		return schemaURL, nil
+	}
+
+	switch {
+	case buildInfo.Version != "" && buildInfo.Version != "(devel)":
+		version, err := hcversion.NewVersion(buildInfo.Version)
+		if err != nil {
+			return "", fmt.Errorf("parse version: %w", err)
+		}
+
+		schemaURL = fmt.Sprintf("https://golangci-lint.run/jsonschema/golangci.v%d.%d.jsonschema.json",
+			version.Segments()[0], version.Segments()[1])
+
+	case buildInfo.Commit != "" && buildInfo.Commit != "?":
+		if buildInfo.Commit == "unknown" {
+			return "", errors.New("unknown commit information")
+		}
+
+		commit := buildInfo.Commit
+
+		if strings.HasPrefix(commit, "(") {
+			c, _, ok := strings.Cut(strings.TrimPrefix(commit, "("), ",")
+			if !ok {
+				return "", errors.New("commit information not found")
+			}
+
+			commit = c
+		}
+
+		schemaURL = fmt.Sprintf("https://raw.githubusercontent.com/golangci/golangci-lint/%s/jsonschema/golangci.next.jsonschema.json",
+			commit)
+
+	default:
+		return "", errors.New("version not found")
+	}
+
+	return schemaURL, nil
+}
+
+func validateConfiguration(schemaPath, targetFile string) error {
+	compiler := jsonschema.NewCompiler()
+	compiler.Draft = jsonschema.Draft7
+
+	schema, err := compiler.Compile(schemaPath)
+	if err != nil {
+		return fmt.Errorf("compile schema: %w", err)
+	}
+
+	var m any
+
+	switch strings.ToLower(filepath.Ext(targetFile)) {
+	case ".yaml", ".yml", ".json":
+		m, err = decodeYamlFile(targetFile)
+		if err != nil {
+			return err
+		}
+
+	case ".toml":
+		m, err = decodeTomlFile(targetFile)
+		if err != nil {
+			return err
+		}
+
+	default:
+		// unsupported
+		return errors.New("unsupported configuration format")
+	}
+
+	return schema.Validate(m)
+}
+
+func printValidationDetail(cmd *cobra.Command, detail *jsonschema.Detailed) {
+	if detail.Error != "" {
+		cmd.PrintErrf("jsonschema: %q does not validate with %q: %s\n",
+			strings.ReplaceAll(strings.TrimPrefix(detail.InstanceLocation, "/"), "/", "."), detail.KeywordLocation, detail.Error)
+	}
+
+	for _, d := range detail.Errors {
+		d := d
+		printValidationDetail(cmd, &d)
+	}
+}
+
+func decodeYamlFile(filename string) (any, error) {
+	file, err := os.Open(filename)
+	if err != nil {
+		return nil, fmt.Errorf("[%s] file open: %w", filename, err)
+	}
+
+	defer func() { _ = file.Close() }()
+
+	var m any
+	err = yaml.NewDecoder(file).Decode(&m)
+	if err != nil {
+		return nil, fmt.Errorf("[%s] YAML decode: %w", filename, err)
+	}
+
+	return m, nil
+}
+
+func decodeTomlFile(filename string) (any, error) {
+	file, err := os.Open(filename)
+	if err != nil {
+		return nil, fmt.Errorf("[%s] file open: %w", filename, err)
+	}
+
+	defer func() { _ = file.Close() }()
+
+	var m any
+	err = toml.NewDecoder(file).Decode(&m)
+	if err != nil {
+		return nil, fmt.Errorf("[%s] TOML decode: %w", filename, err)
+	}
+
+	return m, nil
+}
diff --git a/pkg/commands/config_verify_test.go b/pkg/commands/config_verify_test.go
new file mode 100644
index 00000000..81d16e28
--- /dev/null
+++ b/pkg/commands/config_verify_test.go
@@ -0,0 +1,140 @@
+package commands
+
+import (
+	"testing"
+
+	"github.com/spf13/pflag"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_createSchemaURL(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		flag     string
+		info     BuildInfo
+		expected string
+	}{
+		{
+			desc:     "schema flag only",
+			flag:     "https://example.com",
+			expected: "https://example.com",
+		},
+		{
+			desc: "schema flag and build info",
+			flag: "https://example.com",
+			info: BuildInfo{
+				Version: "v1.0.0",
+				Commit:  "cd8b11773c6c1f595e8eb98c0d4310af20ae20df",
+			},
+			expected: "https://example.com",
+		},
+		{
+			desc: "version and commit",
+			info: BuildInfo{
+				Version: "v1.0.0",
+				Commit:  "cd8b11773c6c1f595e8eb98c0d4310af20ae20df",
+			},
+			expected: "https://golangci-lint.run/jsonschema/golangci.v1.0.jsonschema.json",
+		},
+		{
+			desc: "commit only",
+			info: BuildInfo{
+				Commit: "cd8b11773c6c1f595e8eb98c0d4310af20ae20df",
+			},
+			expected: "https://raw.githubusercontent.com/golangci/golangci-lint/cd8b11773c6c1f595e8eb98c0d4310af20ae20df/jsonschema/golangci.next.jsonschema.json",
+		},
+		{
+			desc: "version devel and commit",
+			info: BuildInfo{
+				Version: "(devel)",
+				Commit:  "cd8b11773c6c1f595e8eb98c0d4310af20ae20df",
+			},
+			expected: "https://raw.githubusercontent.com/golangci/golangci-lint/cd8b11773c6c1f595e8eb98c0d4310af20ae20df/jsonschema/golangci.next.jsonschema.json",
+		},
+		{
+			desc: "composite commit info",
+			info: BuildInfo{
+				Version: "",
+				Commit:  `(cd8b11773c6c1f595e8eb98c0d4310af20ae20df, modified: "false", mod sum: "123")`,
+			},
+			expected: "https://raw.githubusercontent.com/golangci/golangci-lint/cd8b11773c6c1f595e8eb98c0d4310af20ae20df/jsonschema/golangci.next.jsonschema.json",
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
+			flags.String("schema", "", "")
+			if test.flag != "" {
+				_ = flags.Set("schema", test.flag)
+			}
+
+			schemaURL, err := createSchemaURL(flags, test.info)
+			require.NoError(t, err)
+
+			assert.Equal(t, test.expected, schemaURL)
+		})
+	}
+}
+
+func Test_createSchemaURL_error(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		info     BuildInfo
+		expected string
+	}{
+		{
+			desc: "commit unknown",
+			info: BuildInfo{
+				Commit: "unknown",
+			},
+			expected: "unknown commit information",
+		},
+		{
+			desc: "commit ?",
+			info: BuildInfo{
+				Commit: "?",
+			},
+			expected: "version not found",
+		},
+		{
+			desc: "version devel only",
+			info: BuildInfo{
+				Version: "(devel)",
+			},
+			expected: "version not found",
+		},
+		{
+			desc: "invalid version",
+			info: BuildInfo{
+				Version: "example",
+			},
+			expected: "parse version: Malformed version: example",
+		},
+		{
+			desc: "invalid composite commit info",
+			info: BuildInfo{
+				Version: "",
+				Commit:  `(cd8b11773c6c1f595e8eb98c0d4310af20ae20df)`,
+			},
+			expected: "commit information not found",
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
+			flags.String("schema", "", "")
+
+			_, err := createSchemaURL(flags, test.info)
+			require.EqualError(t, err, test.expected)
+		})
+	}
+}
diff --git a/pkg/commands/root.go b/pkg/commands/root.go
index be6e22ce..cbb838aa 100644
--- a/pkg/commands/root.go
+++ b/pkg/commands/root.go
@@ -61,7 +61,7 @@ func newRootCommand(info BuildInfo) *rootCommand {
 		newLintersCommand(log).cmd,
 		newRunCommand(log, info).cmd,
 		newCacheCommand().cmd,
-		newConfigCommand(log).cmd,
+		newConfigCommand(log, info).cmd,
 		newVersionCommand(info).cmd,
 		newCustomCommand(log).cmd,
 	)
diff --git a/scripts/website/copy_jsonschema/main.go b/scripts/website/copy_jsonschema/main.go
new file mode 100644
index 00000000..8708bfcf
--- /dev/null
+++ b/scripts/website/copy_jsonschema/main.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+
+	hcversion "github.com/hashicorp/go-version"
+
+	"github.com/golangci/golangci-lint/scripts/website/github"
+)
+
+func main() {
+	err := copySchemas()
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func copySchemas() error {
+	dstDir := filepath.FromSlash("docs/static/jsonschema/")
+
+	err := os.RemoveAll(dstDir)
+	if err != nil {
+		return fmt.Errorf("remove dir: %w", err)
+	}
+
+	err = os.MkdirAll(dstDir, os.ModePerm)
+	if err != nil {
+		return fmt.Errorf("make dir: %w", err)
+	}
+
+	// The key is the destination file.
+	// The value is the source file.
+	files := map[string]string{}
+
+	entries, err := os.ReadDir("jsonschema")
+	if err != nil {
+		return fmt.Errorf("read dir: %w", err)
+	}
+
+	for _, entry := range entries {
+		if strings.HasSuffix(entry.Name(), ".jsonschema.json") {
+			files[entry.Name()] = entry.Name()
+		}
+	}
+
+	latest, err := github.GetLatestVersion()
+	if err != nil {
+		return fmt.Errorf("get latest release version: %w", err)
+	}
+
+	version, err := hcversion.NewVersion(latest)
+	if err != nil {
+		return fmt.Errorf("parse version: %w", err)
+	}
+
+	versioned := fmt.Sprintf("golangci.v%d.%d.jsonschema.json", version.Segments()[0], version.Segments()[1])
+	files[versioned] = "golangci.jsonschema.json"
+
+	for dst, src := range files {
+		err := copyFile(filepath.Join("jsonschema", src), filepath.Join(dstDir, dst))
+		if err != nil {
+			return fmt.Errorf("copy files: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func copyFile(src, dst string) error {
+	source, err := os.Open(src)
+	if err != nil {
+		return fmt.Errorf("open file %s: %w", src, err)
+	}
+
+	defer func() { _ = source.Close() }()
+
+	info, err := source.Stat()
+	if err != nil {
+		return fmt.Errorf("file %s not found: %w", src, err)
+	}
+
+	destination, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, info.Mode())
+	if err != nil {
+		return fmt.Errorf("create file %s: %w", dst, err)
+	}
+
+	defer func() { _ = destination.Close() }()
+
+	_, err = io.Copy(destination, source)
+	if err != nil {
+		return fmt.Errorf("copy file %s to %s: %w", src, dst, err)
+	}
+
+	return nil
+}
diff --git a/scripts/website/expand_templates/main.go b/scripts/website/expand_templates/main.go
index 211a9a29..24128a3f 100644
--- a/scripts/website/expand_templates/main.go
+++ b/scripts/website/expand_templates/main.go
@@ -3,15 +3,13 @@ package main
 import (
 	"encoding/json"
 	"fmt"
-	"io"
 	"log"
-	"net/http"
 	"os"
 	"path/filepath"
 	"strings"
-	"time"
 
 	"github.com/golangci/golangci-lint/internal/renameio"
+	"github.com/golangci/golangci-lint/scripts/website/github"
 	"github.com/golangci/golangci-lint/scripts/website/types"
 )
 
@@ -89,46 +87,6 @@ func processDoc(path string, replacements map[string]string, madeReplacements ma
 	return nil
 }
 
-type latestRelease struct {
-	TagName string `json:"tag_name"`
-}
-
-func getLatestVersion() (string, error) {
-	endpoint := "https://api.github.com/repos/golangci/golangci-lint/releases/latest"
-
-	//nolint:noctx // request timeout handled by the client
-	req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody)
-	if err != nil {
-		return "", fmt.Errorf("prepare a HTTP request: %w", err)
-	}
-
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
-
-	client := &http.Client{Timeout: 2 * time.Second}
-
-	resp, err := client.Do(req)
-
-	if err != nil {
-		return "", fmt.Errorf("get HTTP response for the latest tag: %w", err)
-	}
-
-	defer resp.Body.Close()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return "", fmt.Errorf("read a body for the latest tag: %w", err)
-	}
-
-	release := latestRelease{}
-
-	err = json.Unmarshal(body, &release)
-	if err != nil {
-		return "", fmt.Errorf("unmarshal the body for the latest tag: %w", err)
-	}
-
-	return release.TagName, nil
-}
-
 func buildTemplateContext() (map[string]string, error) {
 	snippets, err := getExampleSnippets()
 	if err != nil {
@@ -150,7 +108,7 @@ func buildTemplateContext() (map[string]string, error) {
 		return nil, fmt.Errorf("read CHANGELOG.md: %w", err)
 	}
 
-	latestVersion, err := getLatestVersion()
+	latestVersion, err := github.GetLatestVersion()
 	if err != nil {
 		return nil, fmt.Errorf("get the latest version: %w", err)
 	}
diff --git a/scripts/website/github/github.go b/scripts/website/github/github.go
new file mode 100644
index 00000000..01690f5f
--- /dev/null
+++ b/scripts/website/github/github.go
@@ -0,0 +1,49 @@
+package github
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+const endpoint = "https://api.github.com/repos/golangci/golangci-lint/releases/latest"
+
+type releaseInfo struct {
+	TagName string `json:"tag_name"`
+}
+
+// GetLatestVersion gets latest release information.
+func GetLatestVersion() (string, error) {
+	//nolint:noctx // request timeout handled by the client
+	req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody)
+	if err != nil {
+		return "", fmt.Errorf("prepare a HTTP request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+	client := &http.Client{Timeout: 2 * time.Second}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("get HTTP response for the latest tag: %w", err)
+	}
+
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("read a body for the latest tag: %w", err)
+	}
+
+	release := releaseInfo{}
+
+	err = json.Unmarshal(body, &release)
+	if err != nil {
+		return "", fmt.Errorf("unmarshal the body for the latest tag: %w", err)
+	}
+
+	return release.TagName, nil
+}