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 <hidde@hhh.computer>
  • Loading branch information
hiddeco committed Aug 22, 2023
1 parent dc40f88 commit 75d3ce6
Show file tree
Hide file tree
Showing 3 changed files with 440 additions and 41 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
github.com/google/go-cmp v0.5.9
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/goware/prefixer v0.0.0-20160118172347-395022866408
github.com/hashicorp/go-cleanhttp v0.5.2
github.com/hashicorp/vault/api v1.9.2
github.com/lib/pq v1.10.9
github.com/mitchellh/go-homedir v1.1.0
Expand Down Expand Up @@ -88,7 +89,6 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.2.1 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
Expand Down
218 changes: 178 additions & 40 deletions version/version.go
Original file line number Diff line number Diff line change
@@ -1,51 +1,68 @@
package version

import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/blang/semver"
"github.com/hashicorp/go-cleanhttp"
"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
// 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
Expand All @@ -61,31 +78,152 @@ func AIsNewerThanB(A, B string) (bool, error) {
return false, nil
}

// RetrieveLatestVersionFromUpstream gets the latest version from the source code at Github
// RetrieveLatestVersionFromUpstream retrieves the most recent release version
// from GitHub. The function returns the latest version as a string, or an
// error if the request fails or the response cannot be parsed.
//
// Deprecated: This function is deprecated in favor of
// RetrieveLatestReleaseVersion, which also provides 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)
}
return comps[1], nil
tag, _, err := RetrieveLatestReleaseVersion()
return strings.TrimPrefix(tag, "v"), err
}

// RetrieveLatestReleaseVersion fetches the latest release version from GitHub.
// Returns the latest version as a string and the release URL, or an error if
// the request failed or the response could not be parsed.
//
// The function first attempts redirection-based retrieval (HTTP 301). It's
// preferred over GitHub API due to no rate limiting, but may break on
// redirect changes. If the first attempt fails, it falls back to the GitHub
// API.
//
// Unlike RetrieveLatestVersionFromUpstream, it returns the tag (e.g. "v3.7.3").
func RetrieveLatestReleaseVersion() (tag, url string, err error) {
const repository = "mozilla/sops"
return newReleaseFetcher().LatestRelease(repository)
}

// newReleaseFetcher creates and returns a new instance of the releaseFetcher,
// preconfigured with the necessary endpoint information for redirection-based
// and API-based release retrieval.
func newReleaseFetcher() releaseFetcher {
return releaseFetcher{
endpoint: "https://github.com",
apiEndpoint: "https://api.github.com",
}
}

// releaseFetcher is a helper struct used for fetching release information
// from GitHub. It encapsulates the necessary endpoints for redirection-based
// and API-based retrieval methods.
type releaseFetcher struct {
endpoint string
apiEndpoint string
}

// LatestRelease retrieves the most recent release version for a given repository
// by first attempting to fetch it using redirection-based approach. If this
// attempt fails, it then falls back to the versioned GitHub API for retrieval.
//
// It returns the latest version as a string along with its corresponding URL, or
// an error in case both retrieval methods are unsuccessful.
//
// This function combines the advantages of both retrieval strategies: the resilience
// of the redirection-based approach and the reliability of the versioned API usage.
// However, it's worth noting that the API usage can be affected by GitHub's rate limiting.
func (f releaseFetcher) LatestRelease(repository string) (tag, url string, err error) {
if tag, url, err = f.LatestReleaseUsingRedirect(repository); err == nil {
return
}
return f.LatestReleaseUsingAPI(repository)
}

// LatestReleaseUsingRedirect fetches the most recent version of a release
// from the GitHub API. It returns the latest version as a string, along with
// its corresponding URL, or an error in case of a failed request or if the
// response couldn't be parsed.
//
// This method employs a customized HTTP client capable of following HTTP 301
// redirects, which might occur due to repository renaming. It's important to
// note that it does not follow HTTP 302 redirects, the type GitHub employs
// for redirecting to the latest release.
//
// Compared to LatestReleaseUsingAPI, this approach circumvents potential GitHub
// API rate limiting. However, it's worth considering that changes in GitHub's
// redirect handling could potentially disrupt its functionality.
func (f releaseFetcher) LatestReleaseUsingRedirect(repository string) (tag, url string, err error) {
client := cleanhttp.DefaultClient()
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 nil
}

resp, err := client.Head(fmt.Sprintf("%s/%s/releases/latest", f.endpoint, 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)
}

// LatestReleaseUsingAPI retrieves the most recent release version from the
// GitHub API. It returns the latest version as a string, along with its
// corresponding URL, or an error in case of request failure or parsing issues
// with the response.
//
// This approach boasts higher reliability compared to
// LatestReleaseUsingRedirect as it leverages the versioned GitHub API.
// However, it can be affected by GitHub API rate limiting.
func (f releaseFetcher) LatestReleaseUsingAPI(repository string) (tag, url string, err error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s/releases/latest", f.apiEndpoint, 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 := cleanhttp.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 75d3ce6

Please sign in to comment.