这是indexloc提供的服务,不要输入任何密码
Skip to content

Add update checker to turborepo #365

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-hclog v0.16.2
github.com/hashicorp/go-retryablehttp v0.6.8
github.com/hashicorp/go-version v1.3.0 // indirect
github.com/karrick/godirwalk v1.16.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/kr/text v0.2.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.6.8 h1:92lWxgpa+fF3FozM4B3UZtHZMJX8T5XT+TFdCxsPyWs=
github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
Expand Down
3 changes: 2 additions & 1 deletion cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import (

const (
// EnvLogLevel is the environment log level
EnvLogLevel = "TURBO_LOG_LEVEL"
EnvLogLevel = "TURBO_LOG_LEVEL"
defaultConfigPath = "~/.config/turborepo"
)

// IsCI returns true if running in a CI/CD environment
Expand Down
12 changes: 12 additions & 0 deletions cli/internal/config/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package config

import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"

"github.com/adrg/xdg"
"github.com/mitchellh/go-homedir"
)

// TurborepoConfig is a configuration object for the logged-in turborepo.com user
Expand Down Expand Up @@ -83,3 +85,13 @@ func ReadUserConfigFile() (*TurborepoConfig, error) {
func DeleteUserConfigFile() error {
return WriteUserConfigFile(&TurborepoConfig{})
}

// GetConfigDir is the directory for Turbo config.
func GetConfigDir() (string, error) {
dir, err := homedir.Expand(defaultConfigPath)
if err != nil {
return "", fmt.Errorf("can't expand path %q: %s", defaultConfigPath, err)
}

return dir, nil
}
216 changes: 216 additions & 0 deletions cli/internal/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Package update is checking for a new version of Turborepo and informs the user
// to update. Most of the logic is copied from planetscale/cli:
// https://github.com/planetscale/cli/blob/main/internal/update/update.go
// and updated to our own needs.
package update

import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"time"
"turbo/internal/config"
"turbo/internal/util"

"github.com/hashicorp/go-version"
"gopkg.in/yaml.v2"
)

type UpdateInfo struct {
Update bool
Reason string
ReleaseInfo *ReleaseInfo
}

// ReleaseInfo stores information about a release
type ReleaseInfo struct {
Version string `json:"tag_name"`
URL string `json:"html_url"`
PublishedAt time.Time `json:"published_at"`
}

// StateEntry stores the information we have checked for a new version. It's
// used to decide whether to check for a new version or not.
type StateEntry struct {
CheckedForUpdateAt time.Time `yaml:"checked-for-update-at"`
LatestRelease ReleaseInfo `yaml:"latest-release"`
}

// CheckVersion checks for the given build version whether there is a new
// version of the CLI or not.
func CheckVersion(ctx context.Context, buildVersion string) error {
if ctx.Err() != nil {
return ctx.Err()
}

path, err := stateFilePath()
if err != nil {
return err
}

updateInfo, err := checkVersion(
ctx,
buildVersion,
path,
latestVersion,
)
if err != nil {
return fmt.Errorf("skipping update, error: %s", err)
}

if !updateInfo.Update {
return fmt.Errorf("skipping update, reason: %s", updateInfo.Reason)
}

util.Printf("\n${BLUE}A new release of turborepo is available: ${CYAN}%s → %s\n", buildVersion, updateInfo.ReleaseInfo.Version)
util.Printf("${YELLOW}%s${RESET}\n", updateInfo.ReleaseInfo.URL)
return nil
}

func checkVersion(
ctx context.Context,
buildVersion, path string,
latestVersionFn func(ctx context.Context, addr string) (*ReleaseInfo, error),
) (*UpdateInfo, error) {
if _, exists := os.LookupEnv("TURBO_NO_UPDATE_NOTIFIER"); exists {
return &UpdateInfo{
Update: false,
Reason: "TURBO_NO_UPDATE_NOTIFIER is set",
}, nil
}

stateEntry, _ := getStateEntry(path)
if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
return &UpdateInfo{
Update: false,
Reason: "Latest version was already checked",
}, nil
}

addr := "https://api.github.com/repos/vercel/turborepo/releases/latest"
info, err := latestVersionFn(ctx, addr)
if err != nil {
return nil, err
}

err = setStateEntry(path, time.Now(), *info)
if err != nil {
return nil, err
}

v1, err := version.NewVersion(info.Version)
if err != nil {
return nil, err
}

v2, err := version.NewVersion(buildVersion)
if err != nil {
return nil, err
}

if v1.LessThanOrEqual(v2) {
return &UpdateInfo{
Update: false,
Reason: fmt.Sprintf("Latest version (%s) is less than or equal to current build version (%s)",
info.Version, buildVersion),
ReleaseInfo: info,
}, nil
}

return &UpdateInfo{
Update: true,
Reason: fmt.Sprintf("Latest version (%s) is greater than the current build version (%s)",
info.Version, buildVersion),
ReleaseInfo: info,
}, nil

}

func latestVersion(ctx context.Context, addr string) (*ReleaseInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", addr, nil)
if err != nil {
return nil, err
}

req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/vnd.github.v3+json")

getToken := func() string {
if t := os.Getenv("GH_TOKEN"); t != "" {
return t
}
return os.Getenv("GITHUB_TOKEN")
}

if token := getToken(); token != "" {
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
}

client := &http.Client{Timeout: time.Second * 15}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

out, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}

success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, fmt.Errorf("error fetching latest release: %v", string(out))
}

var info *ReleaseInfo
err = json.Unmarshal(out, &info)
if err != nil {
return nil, err
}

return info, nil
}

func getStateEntry(stateFilePath string) (*StateEntry, error) {
content, err := ioutil.ReadFile(stateFilePath)
if err != nil {
return nil, err
}

var stateEntry StateEntry
err = yaml.Unmarshal(content, &stateEntry)
if err != nil {
return nil, err
}

return &stateEntry, nil
}

func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error {
data := StateEntry{
CheckedForUpdateAt: t,
LatestRelease: r,
}

content, err := yaml.Marshal(data)
if err != nil {
return err
}
_ = ioutil.WriteFile(stateFilePath, content, 0600)

return nil
}

func stateFilePath() (string, error) {
dir, err := config.GetConfigDir()
if err != nil {
return "", err
}

return filepath.Join(dir, "state.yml"), nil
}
121 changes: 121 additions & 0 deletions cli/internal/update/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package update

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestLatestVersion(t *testing.T) {

var tests = []struct {
name string
resp *ReleaseInfo
statusCode int
}{
{
name: "valid response",
statusCode: 200,
resp: &ReleaseInfo{
Version: "v0.1.0",
},
},
{
name: "non valid response",
statusCode: 400,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
_ = json.NewEncoder(w).Encode(tt.resp)
}))
defer ts.Close()

info, err := latestVersion(context.Background(), ts.URL)

success := tt.statusCode >= 200 && tt.statusCode < 300
if !success {
assert.NotNil(t, err)
} else {
assert.Nil(t, err)
assert.EqualValues(t, tt.resp, info)

}

})
}

}

func TestCheckVersion(t *testing.T) {

var tests = []struct {
name string
buildVersion string
latestVersion string
update bool
lastChecked time.Time
}{
{
name: "new version",
buildVersion: "v0.1.0",
latestVersion: "v0.2.0",
update: true,
},
{
name: "same version",
buildVersion: "v0.2.0",
latestVersion: "v0.2.0",
update: false,
},
{
name: "higher version",
buildVersion: "v0.3.0",
latestVersion: "v0.2.0",
update: false,
},
{
name: "new version, but we already checked in the past 24 hours",
buildVersion: "v0.1.0",
latestVersion: "v0.2.0",
update: false,
lastChecked: time.Now().Add(-time.Hour),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {

dir := t.TempDir()
path := filepath.Join(dir, "state.yml")

if !tt.lastChecked.IsZero() {
err := setStateEntry(path, tt.lastChecked, ReleaseInfo{Version: tt.latestVersion})
assert.Nil(t, err)
}

updateInfo, err := checkVersion(
context.Background(),
tt.buildVersion,
path,
func(ctx context.Context, addr string) (*ReleaseInfo, error) {
return &ReleaseInfo{Version: tt.latestVersion}, nil
},
)

assert.Nil(t, err)
assert.EqualValues(t, tt.update, updateInfo.Update)

})
}

}