Merge branch 'updater' (fix #2, fix #6)

This commit is contained in:
rhysd 2018-01-19 19:58:20 +09:00
commit 8a8c4e5c9a
8 changed files with 417 additions and 62 deletions

View File

@ -26,6 +26,8 @@ If newer version than itself is detected, it downloads released binary from GitH
- Update the binary with rollback support on failure
- Tested on Linux, macOS and Windows (using Travis CI and AppVeyor)
- Many archive and compression formats are supported (zip, tar, gzip, xzip)
- Support private repositories
- Support [GitHub Enterprise][]
And small wrapper CLIs are provided:
@ -81,6 +83,9 @@ It provides `selfupdate` package.
- `selfupdate.UpdateCommand()`: Detect the latest version of given repository and update given command.
- `selfupdate.DetectLatest()`: Detect the latest version of given repository.
- `selfupdate.UpdateTo()`: Update given command to the binary hosted on given URL.
- `selfupdate.Updater`: Context manager of self-upadte process. If you want to customize some behavior
of self-update (e.g. specify API token, use GitHub Enterprise, ...), please make an instance of
`Updater` and use its methods.
Following is the easiest way to use this package.
@ -154,8 +159,55 @@ func confirmAndSelfUpdate() {
}
```
If GitHub API token is set to `[token]` section in `gitconfig` or `$GITHUB_TOKEN` environment variable,
this library will use it to call GitHub REST API. It's useful when reaching rate limits or when using
this library with private repositories.
Please see [the documentation page][GoDoc] for more detail.
This library should work with [GitHub Enterprise][]. To configure API base URL, please setup `Updater`
instance and use its method instead (Actually all functions above are just a shortcuts of methods of
`Updater` instance).
Following is an example of usage with GitHub Enterprise.
```go
import (
"log"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
)
const version = "1.2.3"
func doSelfUpdate(token string) {
v := semver.MustParse(version)
up, err := selfupdate.NewUpdater(selfupdate.Config{
APIToken: token,
EnterpriseBaseURL: "https://github.your.company.com/api/v3",
})
latest, err := up.UpdateSelf(v, "myname/myrepo")
if err != nil {
log.Println("Binary update failed:", err)
return
}
if latest.Version.Equals(v) {
// latest version is the same as current version. It means current binary is up to date.
log.Println("Current binary is the latest version", version)
} else {
log.Println("Successfully updated to version", latest.Version)
log.Println("Release note:\n", latest.ReleaseNotes)
}
}
```
If `APIToken` field is not given, it tries to retrieve API token from `[token]` section of `.gitconfig`
or `$GITHUB_TOKEN` environment variable. If no token is found, it raises an error because GitHub Enterprise
API does not work without authentication.
If your GitHub Enterprise instance's upload URL is different from the base URL, please also set `EnterpriseUploadURL`
field
### Naming Rules of Released Binaries
go-github-selfupdate assumes that released binaries are put for each combination of platforms and archs.
@ -279,3 +331,4 @@ Distributed under the [MIT License](LICENSE)
[AppVeyor]: https://ci.appveyor.com/project/rhysd/go-github-selfupdate/branch/master
[Codecov Status]: https://codecov.io/gh/rhysd/go-github-selfupdate/branch/master/graph/badge.svg
[Codecov]: https://codecov.io/gh/rhysd/go-github-selfupdate
[GitHub Enterprise]: https://enterprise.github.com/home

View File

@ -1,28 +1,17 @@
package selfupdate
import (
"context"
"fmt"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"github.com/blang/semver"
"github.com/google/go-github/github"
gitconfig "github.com/tcnksm/go-gitconfig"
"golang.org/x/oauth2"
)
var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`)
// ReleaseDetector is responsible for detecting the latest release using GitHub Releases API.
type ReleaseDetector struct {
api *github.Client
apiCtx context.Context
}
func findSuitableReleaseAndAsset(rels []*github.RepositoryRelease) (*github.RepositoryRelease, *github.ReleaseAsset, bool) {
// Generate candidates
cs := make([]string, 0, 8)
@ -64,33 +53,15 @@ func findSuitableReleaseAndAsset(rels []*github.RepositoryRelease) (*github.Repo
return nil, nil, false
}
// NewDetector crates a new detector instance. It initializes GitHub API client.
func NewDetector() *ReleaseDetector {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
token, _ = gitconfig.GithubToken()
}
ctx := context.Background()
var auth *http.Client
if token != "" {
src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
auth = oauth2.NewClient(ctx, src)
}
client := github.NewClient(auth)
return &ReleaseDetector{client, ctx}
}
// DetectLatest tries to get the latest version of the repository on GitHub. 'slug' means 'owner/name' formatted string.
func (d *ReleaseDetector) DetectLatest(slug string) (release *Release, found bool, err error) {
func (up *Updater) DetectLatest(slug string) (release *Release, found bool, err error) {
repo := strings.Split(slug, "/")
if len(repo) != 2 || repo[0] == "" || repo[1] == "" {
err = fmt.Errorf("Invalid slug format. It should be 'owner/name': %s", slug)
return
}
rels, res, err := d.api.Repositories.ListReleases(d.apiCtx, repo[0], repo[1], nil)
rels, res, err := up.api.Repositories.ListReleases(up.apiCtx, repo[0], repo[1], nil)
if err != nil {
log.Println("API returned an error response:", err)
if res != nil && res.StatusCode == 404 {
@ -121,10 +92,13 @@ func (d *ReleaseDetector) DetectLatest(slug string) (release *Release, found boo
release = &Release{
AssetURL: url,
AssetByteSize: asset.GetSize(),
AssetID: asset.GetID(),
URL: rel.GetHTMLURL(),
ReleaseNotes: rel.GetBody(),
Name: rel.GetName(),
PublishedAt: &publishedAt,
RepoOwner: repo[0],
RepoName: repo[1],
}
release.Version, err = semver.Make(tag)
@ -133,5 +107,5 @@ func (d *ReleaseDetector) DetectLatest(slug string) (release *Release, found boo
// DetectLatest detects the latest release of the slug (owner/repo).
func DetectLatest(slug string) (*Release, bool, error) {
return NewDetector().DetectLatest(slug)
return DefaultUpdater().DetectLatest(slug)
}

View File

@ -8,14 +8,6 @@ import (
"testing"
)
func TestGitHubTokenEnv(t *testing.T) {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
t.Skip("because $GITHUB_TOKEN is not set")
}
_ = NewDetector()
}
func TestDetectReleaseWithVersionPrefix(t *testing.T) {
r, ok, err := DetectLatest("rhysd/github-clone-all")
if err != nil {
@ -45,9 +37,18 @@ func TestDetectReleaseWithVersionPrefix(t *testing.T) {
if r.AssetByteSize == 0 {
t.Error("Asset's size is unexpectedly zero")
}
if r.AssetID == 0 {
t.Error("Asset's ID is unexpectedly zero")
}
if r.PublishedAt.IsZero() {
t.Error("Release time is unexpectedly zero")
}
if r.RepoOwner != "rhysd" {
t.Error("Repo owner is not correct:", r.RepoOwner)
}
if r.RepoName != "github-clone-all" {
t.Error("Repo name was not properly detectd:", r.RepoName)
}
}
func TestDetectReleasesForVariousArchives(t *testing.T) {
@ -91,9 +92,18 @@ func TestDetectReleasesForVariousArchives(t *testing.T) {
if r.AssetByteSize == 0 {
t.Error("Asset's size is unexpectedly zero")
}
if r.AssetID == 0 {
t.Error("Asset's ID is unexpectedly zero")
}
if r.PublishedAt.IsZero() {
t.Error("Release time is unexpectedly zero")
}
if r.RepoOwner != "rhysd-test" {
t.Error("Repo owner should be rhysd-test:", r.RepoOwner)
}
if !strings.HasPrefix(r.RepoName, "test-release-") {
t.Error("Repo name was not properly detectd:", r.RepoName)
}
})
}
}
@ -119,7 +129,7 @@ func TestDetectNoRelease(t *testing.T) {
}
func TestInvalidSlug(t *testing.T) {
d := NewDetector()
up := DefaultUpdater()
for _, slug := range []string{
"foo",
@ -128,7 +138,7 @@ func TestInvalidSlug(t *testing.T) {
"/bar",
"foo/bar/piyo",
} {
_, _, err := d.DetectLatest(slug)
_, _, err := up.DetectLatest(slug)
if err == nil {
t.Error(slug, "should be invalid slug")
}
@ -139,8 +149,7 @@ func TestInvalidSlug(t *testing.T) {
}
func TestNonExistingRepo(t *testing.T) {
d := NewDetector()
v, ok, err := d.DetectLatest("rhysd/non-existing-repo")
v, ok, err := DetectLatest("rhysd/non-existing-repo")
if err != nil {
t.Fatal("Non-existing repo should not cause an error:", v)
}
@ -150,8 +159,7 @@ func TestNonExistingRepo(t *testing.T) {
}
func TestNoReleaseFound(t *testing.T) {
d := NewDetector()
_, ok, err := d.DetectLatest("rhysd/misc")
_, ok, err := DetectLatest("rhysd/misc")
if err != nil {
t.Fatal("Repo having no release should not cause an error:", err)
}
@ -159,3 +167,48 @@ func TestNoReleaseFound(t *testing.T) {
t.Fatal("Repo having no release should not be found")
}
}
func TestDetectFromBrokenGitHubEnterpriseURL(t *testing.T) {
up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"})
if err != nil {
t.Fatal(err)
}
_, ok, _ := up.DetectLatest("foo/bar")
if ok {
t.Fatal("Invalid GitHub Enterprise base URL should raise an error")
}
}
func TestDetectFromGitHubEnterpriseRepo(t *testing.T) {
token := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL")
repo := os.Getenv("GITHUB_ENTERPRISE_REPO")
if token == "" {
t.Skip("because token for GHE is not found")
}
if base == "" {
t.Skip("because base URL for GHE is not found")
}
if repo == "" {
t.Skip("because repo slug for GHE is not found")
}
up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base})
if err != nil {
t.Fatal(err)
}
r, ok, err := up.DetectLatest(repo)
if err != nil {
t.Fatal("Fetch failed:", err)
}
if !ok {
t.Fatal(repo, "not found")
}
if r == nil {
t.Fatal("Release not detected")
}
if !r.Version.Equals(semver.MustParse("1.2.3")) {
t.Error("")
}
}

View File

@ -13,6 +13,8 @@ type Release struct {
AssetURL string
// AssetSize represents the size of asset in bytes
AssetByteSize int
// AssetID is the ID of the asset on GitHub
AssetID int
// URL is a URL to release page for browsing
URL string
// ReleaseNotes is a release notes of the release
@ -21,4 +23,8 @@ type Release struct {
Name string
// PublishedAt is the time when the release was published
PublishedAt *time.Time
// RepoOwner is the owner of the repository of the release
RepoOwner string
// RepoName is the name of the repository of the release
RepoName string
}

View File

@ -2,6 +2,7 @@ package selfupdate
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@ -12,20 +13,11 @@ import (
"github.com/inconshreveable/go-update"
)
// UpdateTo download an executable from assetURL and replace the current binary with the downloaded one. cmdPath is a file path to command executable.
func UpdateTo(assetURL, cmdPath string) error {
res, err := http.Get(assetURL)
if err != nil {
return fmt.Errorf("Failed to download a release file from %s: %s", assetURL, err)
}
func uncompressAndUpdate(src io.ReadCloser, assetURL, cmdPath string) error {
defer src.Close()
if res.StatusCode != 200 {
return fmt.Errorf("Failed to download a release file from %s", assetURL)
}
defer res.Body.Close()
_, cmd := filepath.Split(cmdPath)
asset, err := UncompressCommand(res.Body, assetURL, cmd)
asset, err := UncompressCommand(src, assetURL, cmd)
if err != nil {
return err
}
@ -36,9 +28,51 @@ func UpdateTo(assetURL, cmdPath string) error {
})
}
func (up *Updater) downloadDirectlyFromURL(assetURL string) (io.ReadCloser, error) {
req, err := http.NewRequest("GET", assetURL, nil)
if err != nil {
return nil, fmt.Errorf("Failed to create HTTP request to %s: %s", assetURL, err)
}
req.Header.Add("Accept", "application/octet-stream")
req = req.WithContext(up.apiCtx)
// OAuth HTTP client is not available to download blob from URL when the URL is a redirect URL
// returned from GitHub Releases API (response status 400).
// Use default HTTP client instead.
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Failed to download a release file from %s: %s", assetURL, err)
}
if res.StatusCode != 200 {
return nil, fmt.Errorf("Failed to download a release file from %s: Not successful status %d", assetURL, res.StatusCode)
}
return res.Body, nil
}
// UpdateTo downloads an executable from GitHub Releases API and replace current binary with the downloaded one.
// It downloads a release asset via GitHub Releases API so this function is available for update releases on private repository.
// If a redirect occurs, it fallbacks into directly downloading from the redirect URL.
func (up *Updater) UpdateTo(rel *Release, cmdPath string) error {
src, redirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.AssetID)
if err != nil {
return fmt.Errorf("Failed to call GitHub Releases API for getting an asset(ID: %d) for repository '%s/%s': %s", rel.AssetID, rel.RepoOwner, rel.RepoName, err)
}
if redirectURL != "" {
log.Println("Redirect URL was returned while trying to download a release asset from GitHub API. Falling back to downloading from asset URL directly:", redirectURL)
src, err = up.downloadDirectlyFromURL(redirectURL)
if err != nil {
return err
}
}
return uncompressAndUpdate(src, rel.AssetURL, cmdPath)
}
// UpdateCommand updates a given command binary to the latest version.
// 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version.
func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) {
func (up *Updater) UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) {
if runtime.GOOS == "windows" && !strings.HasSuffix(cmdPath, ".exe") {
// Ensure to add '.exe' to given path on Windows
cmdPath = cmdPath + ".exe"
@ -56,7 +90,7 @@ func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Releas
cmdPath = p
}
rel, ok, err := DetectLatest(slug)
rel, ok, err := up.DetectLatest(slug)
if err != nil {
return nil, err
}
@ -69,7 +103,7 @@ func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Releas
return rel, nil
}
log.Println("Will update", cmdPath, "to the latest version", rel.Version)
if err := UpdateTo(rel.AssetURL, cmdPath); err != nil {
if err := up.UpdateTo(rel, cmdPath); err != nil {
return nil, err
}
return rel, nil
@ -77,10 +111,35 @@ func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Releas
// UpdateSelf updates the running executable itself to the latest version.
// 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version.
func UpdateSelf(current semver.Version, slug string) (*Release, error) {
func (up *Updater) UpdateSelf(current semver.Version, slug string) (*Release, error) {
cmdPath, err := os.Executable()
if err != nil {
return nil, err
}
return UpdateCommand(cmdPath, current, slug)
}
// UpdateTo downloads an executable from assetURL and replace the current binary with the downloaded one.
// This function is low-level API to update the binary. Because it does not use GitHub API and downloads asset directly from the URL via HTTP,
// this function is not available to update a release for private repositories.
// cmdPath is a file path to command executable.
func UpdateTo(assetURL, cmdPath string) error {
up := DefaultUpdater()
src, err := up.downloadDirectlyFromURL(assetURL)
if err != nil {
return err
}
return uncompressAndUpdate(src, assetURL, cmdPath)
}
// UpdateCommand updates a given command binary to the latest version.
// This function is a shortcut version of updater.UpdateCommand.
func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) {
return DefaultUpdater().UpdateCommand(cmdPath, current, slug)
}
// UpdateSelf updates the running executable itself to the latest version.
// This function is a shortcut version of updater.UpdateSelf.
func UpdateSelf(current semver.Version, slug string) (*Release, error) {
return DefaultUpdater().UpdateSelf(current, slug)
}

View File

@ -256,3 +256,61 @@ func TestBrokenAsset(t *testing.T) {
t.Fatal("Unexpected error:", err)
}
}
func TestBrokenGitHubEnterpriseURL(t *testing.T) {
up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"})
if err != nil {
t.Fatal(err)
}
err = up.UpdateTo(&Release{AssetURL: "https://example.com"}, "foo")
if err == nil {
t.Fatal("Invalid GitHub Enterprise base URL should raise an error")
}
if !strings.Contains(err.Error(), "Failed to call GitHub Releases API for getting an asset") {
t.Error("Unexpected error occurred:", err)
}
}
func TestUpdateFromGitHubEnterprise(t *testing.T) {
token := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL")
repo := os.Getenv("GITHUB_ENTERPRISE_REPO")
if token == "" {
t.Skip("because token for GHE is not found")
}
if base == "" {
t.Skip("because base URL for GHE is not found")
}
if repo == "" {
t.Skip("because repo slug for GHE is not found")
}
setupTestBinary()
defer teardownTestBinary()
up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base})
if err != nil {
t.Fatal(err)
}
latest := semver.MustParse("1.2.3")
prev := semver.MustParse("1.2.2")
rel, err := up.UpdateCommand("github-release-test", prev, repo)
if err != nil {
t.Fatal(err)
}
if rel.Version.NE(latest) {
t.Error("Version is not latest", rel.Version)
}
bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output()
if err != nil {
t.Fatal("Failed to run test binary after update:", err)
}
out := string(bytes)
if out != "v1.2.3\n" {
t.Error("Output from test binary after update is unexpected:", out)
}
}

85
selfupdate/updater.go Normal file
View File

@ -0,0 +1,85 @@
package selfupdate
import (
"context"
"errors"
"net/http"
"os"
"github.com/google/go-github/github"
gitconfig "github.com/tcnksm/go-gitconfig"
"golang.org/x/oauth2"
)
// Updater is responsible for managing the context of self-update.
// It contains GitHub client and its context.
type Updater struct {
api *github.Client
apiCtx context.Context
httpClient *http.Client
}
// Config represents the configuration of self-update.
type Config struct {
// APIToken represents GitHub API token. If it's not empty, it will be used for authentication of GitHub API
APIToken string
// EnterpriseBaseURL is a base URL of GitHub API. If you want to use this library with GitHub Enterprise,
// please set "https://{your-organization-address}/api/v3/" to this field.
EnterpriseBaseURL string
// EnterpriseUploadURL is a URL to upload stuffs to GitHub Enterprise instance. This is often the same as an API base URL.
// So if this field is not set and EnterpriseBaseURL is set, EnterpriseBaseURL is also set to this field.
EnterpriseUploadURL string
}
func newHTTPClient(ctx context.Context, token string) *http.Client {
if token == "" {
return http.DefaultClient
}
src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
return oauth2.NewClient(ctx, src)
}
// NewUpdater creates a new updater instance. It initializes GitHub API client.
// If you set your API token to $GITHUB_TOKEN, the client will use it.
func NewUpdater(config Config) (*Updater, error) {
token := config.APIToken
if token == "" {
token = os.Getenv("GITHUB_TOKEN")
}
if token == "" {
token, _ = gitconfig.GithubToken()
}
ctx := context.Background()
hc := newHTTPClient(ctx, token)
if config.EnterpriseBaseURL == "" {
client := github.NewClient(hc)
return &Updater{client, ctx, hc}, nil
}
if token == "" {
return nil, errors.New("GitHub API token cannot be empty when releases are hosted on GitHub Enterprise instance")
}
u := config.EnterpriseUploadURL
if u == "" {
u = config.EnterpriseBaseURL
}
client, err := github.NewEnterpriseClient(config.EnterpriseBaseURL, u, hc)
if err != nil {
return nil, err
}
return &Updater{client, ctx, hc}, nil
}
// DefaultUpdater creates a new updater instance with default configuration.
// It initializes GitHub API client with default API base URL.
// If you set your API token to $GITHUB_TOKEN, the client will use it.
func DefaultUpdater() *Updater {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
token, _ = gitconfig.GithubToken()
}
ctx := context.Background()
client := newHTTPClient(ctx, token)
return &Updater{github.NewClient(client), ctx, client}
}

View File

@ -0,0 +1,67 @@
package selfupdate
import (
"os"
"testing"
)
func TestGitHubTokenEnv(t *testing.T) {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
t.Skip("because $GITHUB_TOKEN is not set")
}
_ = DefaultUpdater()
if _, err := NewUpdater(Config{}); err != nil {
t.Error("Failed to initialize updater with empty config")
}
if _, err := NewUpdater(Config{APIToken: token}); err != nil {
t.Error("Failed to initialize updater with API token config")
}
}
func TestGitHubEnterpriseClient(t *testing.T) {
url := "https://github.company.com/api/v3/"
up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: url})
if err != nil {
t.Fatal(err)
}
if up.api.BaseURL.String() != url {
t.Error("Base URL was set to", up.api.BaseURL, ", want", url)
}
if up.api.UploadURL.String() != url {
t.Error("Upload URL was set to", up.api.UploadURL, ", want", url)
}
url2 := "https://upload.github.company.com/api/v3/"
up, err = NewUpdater(Config{
APIToken: "hogehoge",
EnterpriseBaseURL: url,
EnterpriseUploadURL: url2,
})
if err != nil {
t.Fatal(err)
}
if up.api.BaseURL.String() != url {
t.Error("Base URL was set to", up.api.BaseURL, ", want", url)
}
if up.api.UploadURL.String() != url2 {
t.Error("Upload URL was set to", up.api.UploadURL, ", want", url2)
}
}
func TestGitHubEnterpriseClientWithoutToken(t *testing.T) {
token := os.Getenv("GITHUB_TOKEN")
defer os.Setenv("GITHUB_TOKEN", token)
os.Setenv("GITHUB_TOKEN", "")
_, err := NewUpdater(Config{EnterpriseBaseURL: "https://github.company.com/api/v3/"})
if err == nil {
t.Fatal("Error should be reported because of empty token")
}
}
func TestGitHubEnterpriseClientInvalidURL(t *testing.T) {
_, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: ":this is not a URL"})
if err == nil {
t.Fatal("Invalid URL should raise an error")
}
}