Skip to content

Commit

Permalink
version: rewrite command to use GitHub endpoints
Browse files Browse the repository at this point in the history
This changes the logic of parsing the `version.go` file from a certain
branch to instead make use of the GitHub latest release redirect or
API[1] endpoints for checking if `sops` is on the latest version.

Detaching any future release of SOPS from specific file structures
and/or branches, and (theoretically) freeing it from the requirement of
having to bump the version in-code during release (as this is also done
using `-ldflags` during build). Were it not for the fact that we have
to maintain it for backwards compatibility.

[1]: https://docs.github.com/en/free-pro-team@latest/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release

Signed-off-by: Hidde Beydals <[email protected]>
  • Loading branch information
hiddeco committed Aug 17, 2023
1 parent 344132e commit 41c32bb
Showing 2 changed files with 444 additions and 40 deletions.
202 changes: 162 additions & 40 deletions version/version.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package version

import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -10,42 +10,70 @@ import (
"github.com/urfave/cli"
)

// Version represents the value of the current semantic version
// Version represents the value of the current semantic version.
var Version = "3.7.3"

// PrintVersion handles the version command for sops
// These variables are used to retrieve the latest version of SOPS
// from GitHub, while also allowing them to be overridden for testing
// purposes.
var (
// githubHost is the URL of the GitHub website.
githubHost = "https://github.com"
// githubAPIHost is the URL of the GitHub API.
githubAPIHost = "https://api.github.com"
// githubRepository is the repository name of SOPS on GitHub.
githubRepository = "getsops/sops"
)

// PrintVersion prints the current version of sops. If the flag
// `--disable-version-check` is set, the function will not attempt
// to retrieve the latest version from the GitHub API.
//
// If the flag is not set, the function will attempt to retrieve
// the latest version from the GitHub API and compare it to the
// current version. If the latest version is newer, the function
// will print a message to stdout.
func PrintVersion(c *cli.Context) {
out := fmt.Sprintf("%s %s", c.App.Name, c.App.Version)
out := strings.Builder{}

out.WriteString(fmt.Sprintf("%s %s", c.App.Name, c.App.Version))

if c.Bool("disable-version-check") {
out += "\n"
out.WriteString("\n")
} else {
upstreamVersion, err := RetrieveLatestVersionFromUpstream()
if err != nil {
out += fmt.Sprintf("\n[warning] failed to retrieve latest version from upstream: %v\n", err)
}
outdated, err := AIsNewerThanB(upstreamVersion, Version)
upstreamVersion, upstreamURL, err := RetrieveLatestReleaseVersion()
if err != nil {
out += fmt.Sprintf("\n[warning] failed to compare current version with latest: %v\n", err)
out.WriteString(fmt.Sprintf("\n[warning] failed to retrieve latest version from upstream: %v\n", err))
} else {
if outdated {
out += fmt.Sprintf("\n[info] sops %s is available, update with `go get -u github.com/getsops/sops/v3/cmd/sops`\n", upstreamVersion)
outdated, err := AIsNewerThanB(upstreamVersion, Version)
if err != nil {
out.WriteString(fmt.Sprintf("\n[warning] failed to compare current version with latest: %v\n", err))
} else {
out += " (latest)\n"
if outdated {
out.WriteString(fmt.Sprintf("\n[info] a new version of sops (%s) is available, you can update by visiting: %s\n", upstreamVersion, upstreamURL))
} else {
out.WriteString(" (latest)\n")
}
}
}
}
fmt.Fprintf(c.App.Writer, "%s", out)
fmt.Fprintf(c.App.Writer, out.String())
}

// AIsNewerThanB takes 2 semver strings are returns true
// is the A is newer than B, false otherwise
// AIsNewerThanB compares two semantic versions and returns true if A is newer
// than B. The function will return an error if either version is not a valid
// semantic version.
func AIsNewerThanB(A, B string) (bool, error) {
if strings.HasPrefix(B, "1.") {
// sops 1.0 doesn't use the semver format, which will
// fail the call to `make` below. Since we now we're
// more recent than 1.X anyway, return true right away
return true, nil
}

// Trim the leading "v" from the version strings, if present.
A, B = strings.TrimPrefix(A, "v"), strings.TrimPrefix(B, "v")

vA, err := semver.Make(A)
if err != nil {
return false, err
@@ -61,31 +89,125 @@ func AIsNewerThanB(A, B string) (bool, error) {
return false, nil
}

// RetrieveLatestVersionFromUpstream gets the latest version from the source code at Github
// RetrieveLatestVersionFromUpstream fetches the latest release version
// from the GitHub API. The function returns the latest version as a string,
// or an error if the request failed or the response could not be parsed.
//
// Deprecated: This function is deprecated in favor of
// RetrieveLatestReleaseVersion, which also returns the URL of the latest
// release.
func RetrieveLatestVersionFromUpstream() (string, error) {
resp, err := http.Get("https://raw.githubusercontent.com/getsops/sops/master/version/version.go")
if err != nil {
return "", err
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, `const Version = "`) {
comps := strings.Split(line, `"`)
if len(comps) < 2 {
return "", fmt.Errorf("Failed to parse version from upstream source")
}
// try to parse the version as semver
_, err := semver.Make(comps[1])
if err != nil {
return "", fmt.Errorf("Retrieved version %q does not match semver format: %w", comps[1], err)
tag, _, err := RetrieveLatestReleaseVersion()
return strings.TrimPrefix(tag, "v"), err
}

// RetrieveLatestReleaseVersion fetches the latest release version from
// GitHub. The function returns the latest version as a string and the URL of
// the release, or an error if the request failed or the response could not be
// parsed.
//
// The function first attempts to retrieve the latest release version by
// following HTTP 301 redirects. This is preferred over using the GitHub API
// because it does not suffer from GitHub API rate limiting. But may break if
// GitHub changes the way it handles redirects.
//
// If the first attempt fails, the function falls back to using the GitHub API.
// This is less preferred because it suffers from GitHub API rate limiting.
//
// Note that unlike RetrieveLatestVersionFromUpstream, the function returns the
// tag name of the latest release, which may be prefixed with a "v" (e.g.
// "v3.7.3").
func RetrieveLatestReleaseVersion() (tag, url string, err error) {
tag, url, err = retrieveLatestReleaseVersionUsingRedirect(githubHost, githubRepository)
if err == nil {
return tag, url, nil
}
return retrieveLatestReleaseVersionUsingAPI(githubAPIHost, githubRepository)
}

// retrieveLatestReleaseVersionUsingRedirect fetches the latest release version
// from the GitHub API. The function returns the latest version as a string,
// or an error if the request failed or the response could not be parsed.
//
// This function uses a custom HTTP client that follows HTTP 301 redirects,
// which may be present due to the repository being renamed. But it does not
// follow HTTP 302 redirects, which is what GitHub uses to redirect to the
// latest release.
//
// This function is preferred over retrieveLatestReleaseVersionUsingAPI because
// it does not suffer from GitHub API rate limiting. But may break if GitHub
// changes the way it handles redirects.
func retrieveLatestReleaseVersionUsingRedirect(host, repository string) (tag, url string, err error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Follow HTTP 301 redirects, which may be present due to the
// repository being renamed. But do not follow HTTP 302 redirects,
// which is what GitHub uses to redirect to the latest release.
if req.Response.StatusCode == 302 {
return http.ErrUseLastResponse
}
return comps[1], nil
}
return nil
},
}

resp, err := client.Get(fmt.Sprintf("%s/%s/releases/latest", host, repository))
if err != nil {
return "", "", err
}
if resp.Body != nil {
defer resp.Body.Close()
}

if resp.StatusCode < 300 || resp.StatusCode > 399 {
return "", "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

location := resp.Header.Get("Location")
if location == "" {
return "", "", fmt.Errorf("missing Location header")
}

tagMarker := "releases/tag/"
if tagIndex := strings.Index(location, tagMarker); tagIndex != -1 {
return location[tagIndex+len(tagMarker):], location, nil
}
return "", "", fmt.Errorf("unexpected Location header: %s", location)
}

// retrieveLatestReleaseVersionUsingAPI fetches the latest release version
// from the GitHub API. The function returns the latest version as a string,
// or an error if the request failed or the response could not be parsed.
//
// This function is more reliable than retrieveLatestReleaseVersionUsingRedirect
// due to making use of the versioned GitHub API. But it suffers from GitHub API
// rate limiting.
func retrieveLatestReleaseVersionUsingAPI(host, repository string) (tag, url string, err error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s/releases/latest", host, repository), nil)
if err != nil {
return "", "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("GitHub API request failed: %v", err)
}
if res.Body != nil {
defer res.Body.Close()
}

if res.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("GitHub API request failed with status code: %d", res.StatusCode)
}

type release struct {
URL string `json:"html_url"`
Tag string `json:"tag_name"`
}
if err := scanner.Err(); err != nil {
return "", err
var m release
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return "", "", err
}
return "", fmt.Errorf("Version information not found in upstream file")
return m.Tag, m.URL, nil
}
Loading

0 comments on commit 41c32bb

Please sign in to comment.