diff --git a/.appveyor.yml b/.appveyor.yml new file mode 100644 index 0000000..0d950ff --- /dev/null +++ b/.appveyor.yml @@ -0,0 +1,29 @@ +version: "{build}" +clone_depth: 1 +clone_folder: c:\outside-gopath +environment: + GOPATH: c:\gopath + GO111MODULE: on +install: + - echo %PATH% + - echo %GOPATH% + - go version + - go env + - go get -v -t -d ./... +build: off +test_script: + - go build ./selfupdate + - go build ./cmd/selfupdate-example + - go build ./cmd/detect-latest-release + - go build ./cmd/go-get-release/ + - ps: | + if (Test-Path env:GITHUB_TOKEN) { + go test -v -race "-coverprofile=coverage.txt" ./selfupdate + } else { + go test -v -race -short ./selfupdate + } +after_test: + - "SET PATH=C:\\Python34;C:\\Python34\\Scripts;%PATH%" + - pip install codecov + - codecov -f "coverage.txt" +deploy: off diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d9a6f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/selfupdate-example +/release +/env.sh +/detect-latest-release +/go-get-release +/coverage.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..362b79b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: go +os: + - linux + - osx +env: + - GO111MODULE=on +install: + - go version + - go env + - go get -t -d -v ./... +script: + - go build ./selfupdate/ + - go build ./cmd/selfupdate-example/ + - go build ./cmd/detect-latest-release/ + - go build ./cmd/go-get-release/ + - | + if [[ "${GITHUB_TOKEN}" != "" ]]; then + go test -v -race -coverprofile=coverage.txt ./selfupdate + else + go test -v -race -short ./selfupdate + fi +after_success: + - if [ -f coverage.txt ]; then bash <(curl -s https://codecov.io/bash); fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f6045d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +## [v1.2.3] - 2021-01-13 + +- Fix security issues in dependencies; CVE-2020-16845, CVE-2019-11840, CVE-2020-14040 (Thanks to [@bhamail](https://github.com/bhamail)). + +## [v1.2.2] - 2020-04-10 + +- Update `go-github` dependency to v30.1.0 + +## [v1.2.1] - 2019-12-19 + +- Fix `.tgz` file was not handled as `.tar.gz`. + + +## [v1.2.0] - 2019-12-19 + +- New Feature: Filtering releases by matching regular expressions to release names (Thanks to [@fredbi](https://github.com/fredbi)). + Regular expression strings specified at `Filters` field in `Config` struct are used on detecting the + latest release. Please read [documentation](https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate#Config) + for more details. +- Allow `{cmd}_{os}_{arch}` format for executable names. +- `.tgz` file name suffix was supported. + + +## [v1.1.0] - 2018-11-10 + +- New Feature: Signature validation for release assets (Thanks to [@tobiaskohlbau](https://github.com/tobiaskohlbau)). + Please read [the instruction](https://github.com/rhysd/go-github-selfupdate#hash-or-signature-validation) for usage. + + +## [v1.0.0] - 2018-09-23 + +First release! :tada: + + +[v1.2.3]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.2...v1.2.3 +[v1.2.2]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.1...v1.2.2 +[v1.2.1]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.0...v1.2.1 +[v1.2.0]: https://github.com/rhysd/go-github-selfupdate/compare/go-get-release...v1.2.0 +[v1.1.0]: https://github.com/rhysd/go-github-selfupdate/compare/v1.0.0...v1.1.0 +[v1.0.0]: https://github.com/rhysd/go-github-selfupdate/compare/example-1.2.4...v1.0.0 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..c548e98 --- /dev/null +++ b/Guardfile @@ -0,0 +1,18 @@ +guard :shell do + watch /^selfupdate\/.+\.go$/ do |m| + puts "#{Time.now}: #{m[0]}" + case m[0] + when /_test\.go$/ + parent = File.dirname m[0] + sources = Dir["#{parent}/*.go"].reject{|p| p.end_with? '_test.go'}.join(' ') + system "go test -v -short #{m[0]} #{sources}" + else + system 'go build ./selfupdate/' + end + end + + watch /^cmd\/selfupdate-example\/.+\.go$/ do |m| + puts "#{Time.now}: #{m[0]}" + system 'go build ./cmd/selfupdate-example/' + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..540fd4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +the MIT License + +Copyright (c) 2017 rhysd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5daa089 --- /dev/null +++ b/README.md @@ -0,0 +1,444 @@ +Self-Update Mechanism for Go Commands Using GitHub +================================================== + +[![GoDoc Badge][]][GoDoc] +[![TravisCI Status][]][TravisCI] +[![AppVeyor Status][]][AppVeyor] +[![Codecov Status][]][Codecov] + +[go-github-selfupdate][] is a Go library to provide a self-update mechanism to command line tools. + +Go does not provide a way to install/update the stable version of tools. By default, Go command line +tools are updated: + +1. using `go get -u`, but it is not stable because HEAD of the repository is built +2. using system's package manager, but it is harder to release because of depending on the platform +3. downloading executables from GitHub release page, but it requires users to download and put it in an executable path manually + +[go-github-selfupdate][] resolves the problem of 3 by detecting the latest release, downloading it and +putting it in `$GOPATH/bin` automatically. + +[go-github-selfupdate][] detects the information of the latest release via [GitHub Releases API][] and +checks the current version. If a newer version than itself is detected, it downloads the released binary from +GitHub and replaces itself. + +- Automatically detect the latest version of released binary on GitHub +- Retrieve the proper binary for the OS and arch where the binary is running +- 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][] +- Support hash, signature validation (thanks to [@tobiaskohlbau](https://github.com/tobiaskohlbau)) + +And small wrapper CLIs are provided: + +- [detect-latest-release](./cmd/detect-latest-release): Detect the latest release of given GitHub repository from command line +- [go-get-release](./cmd/go-get-release): Like `go get`, but install a release binary from GitHub instead + +[Slide at GoCon 2018 Spring (Japanese)](https://speakerdeck.com/rhysd/go-selfupdate-github-de-turuwozi-ji-atupudetosuru) + +[go-github-selfupdate]: https://github.com/rhysd/go-github-selfupdate +[GitHub Releases API]: https://developer.github.com/v3/repos/releases/ + + + +## Try Out Example + +Example to understand what this library does is prepared as [CLI](./cmd/selfupdate-example/main.go). + +Install it at first. + +``` +$ go get -u github.com/rhysd/go-github-selfupdate/cmd/selfupdate-example +``` + +And check the version by `-version`. `-help` flag is also available to know all flags. + +``` +$ selfupdate-example -version +``` + +It should show `v1.2.3`. + +Then run `-selfupdate` + +``` +$ selfupdate-example -selfupdate +``` + +It should replace itself and finally show a message containing release notes. + +Please check the binary version is updated to `v1.2.4` with `-version`. The binary is up-to-date. +So running `-selfupdate` again only shows 'Current binary is the latest version'. + +### Real World Examples + +Following tools are using this library. + +- [dot-github](https://github.com/rhysd/dot-github) +- [dotfiles](https://github.com/rhysd/dotfiles) +- [github-clone-all](https://github.com/rhysd/github-clone-all) +- [pythonbrew](https://github.com/utahta/pythonbrew) +- [akashic](https://github.com/cowlick/akashic) +- [butler](https://github.com/netzkern/butler) + + + +## Usage + +### Code Usage + +It provides `selfupdate` package. + +- `selfupdate.UpdateSelf()`: Detect the latest version of itself and run self update. +- `selfupdate.UpdateCommand()`: Detect the latest version of given repository and update given command. +- `selfupdate.DetectLatest()`: Detect the latest version of given repository. +- `selfupdate.DetectVersion()`: Detect the user defined version of given repository. +- `selfupdate.UpdateTo()`: Update given command to the binary hosted on given URL. +- `selfupdate.Updater`: Context manager of self-update 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. + +```go +import ( + "log" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +const version = "1.2.3" + +func doSelfUpdate() { + v := semver.MustParse(version) + latest, err := selfupdate.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) + } +} +``` + +Following asks user to update or not. + +```go +import ( + "bufio" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "log" + "os" +) + +const version = "1.2.3" + +func confirmAndSelfUpdate() { + latest, found, err := selfupdate.DetectLatest("owner/repo") + if err != nil { + log.Println("Error occurred while detecting version:", err) + return + } + + v := semver.MustParse(version) + if !found || latest.Version.LTE(v) { + log.Println("Current version is the latest") + return + } + + fmt.Print("Do you want to update to", latest.Version, "? (y/n): ") + input, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil || (input != "y\n" && input != "n\n") { + log.Println("Invalid input") + return + } + if input == "n\n" { + return + } + + exe, err := os.Executable() + if err != nil { + log.Println("Could not locate executable path") + return + } + if err := selfupdate.UpdateTo(latest.AssetURL, exe); err != nil { + log.Println("Error occurred while updating binary:", err) + return + } + log.Println("Successfully updated to version", latest.Version) +} +``` + +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. + +Note that `os.Args[0]` is not available since it does not provide a full path to executable. Instead, +please use `os.Executable()`. + +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 methods instead (actually all functions above are just a shortcuts of methods of an +`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 the `EnterpriseUploadURL` +field. + + +### Naming Rules of Released Binaries + +go-github-selfupdate assumes that released binaries are put for each combination of platforms and archs. +Binaries for each platform can be easily built using tools like [gox][] + +You need to put the binaries with the following format. + +``` +{cmd}_{goos}_{goarch}{.ext} +``` + +`{cmd}` is a name of command. +`{goos}` and `{goarch}` are the platform and the arch type of the binary. +`{.ext}` is a file extension. go-github-selfupdate supports `.zip`, `.gzip`, `.tar.gz` and `.tar.xz`. +You can also use blank and it means binary is not compressed. + +If you compress binary, uncompressed directory or file must contain the executable named `{cmd}`. + +And you can also use `-` for separator instead of `_` if you like. + +For example, if your command name is `foo-bar`, one of followings is expected to be put in release +page on GitHub as binary for platform `linux` and arch `amd64`. + +- `foo-bar_linux_amd64` (executable) +- `foo-bar_linux_amd64.zip` (zip file) +- `foo-bar_linux_amd64.tar.gz` (tar file) +- `foo-bar_linux_amd64.xz` (xzip file) +- `foo-bar-linux-amd64.tar.gz` (`-` is also ok for separator) + +If you compress and/or archive your release asset, it must contain an executable named one of followings: + +- `foo-bar` (only command name) +- `foo-bar_linux_amd64` (full name) +- `foo-bar-linux-amd64` (`-` is also ok for separator) + +To archive the executable directly on Windows, `.exe` can be added before file extension like +`foo-bar_windows_amd64.exe.zip`. + +[gox]: https://github.com/mitchellh/gox + + +### Naming Rules of Versions (=Git Tags) + +go-github-selfupdate searches binaries' versions via Git tag names (not a release title). +When your tool's version is `1.2.3`, you should use the version number for tag of the Git +repository (i.e. `1.2.3` or `v1.2.3`). + +This library assumes you adopt [semantic versioning][]. It is necessary for comparing versions +systematically. + +Prefix before version number `\d+\.\d+\.\d+` is automatically omitted. For example, `ver1.2.3` or +`release-1.2.3` are also ok. + +Tags which don't contain a version number are ignored (i.e. `nightly`). And releases marked as `pre-release` +are also ignored. + +[semantic versioning]: https://semver.org/ + + +### Structure of Releases + +In summary, structure of releases on GitHub looks like: + +- `v1.2.0` + - `foo-bar-linux-amd64.tar.gz` + - `foo-bar-linux-386.tar.gz` + - `foo-bar-darwin-amd64.tar.gz` + - `foo-bar-windows-amd64.zip` + - ... (Other binaries for v1.2.0) +- `v1.1.3` + - `foo-bar-linux-amd64.tar.gz` + - `foo-bar-linux-386.tar.gz` + - `foo-bar-darwin-amd64.tar.gz` + - `foo-bar-windows-amd64.zip` + - ... (Other binaries for v1.1.3) +- ... (older versions) + + +### Hash or Signature Validation + +go-github-selfupdate supports hash or signature validatiom of the downloaded files. It comes +with support for sha256 hashes or ECDSA signatures. In addition to internal functions the +user can implement the `Validator` interface for own validation mechanisms. + +```go +// Validator represents an interface which enables additional validation of releases. +type Validator interface { + // Validate validates release bytes against an additional asset bytes. + // See SHA2Validator or ECDSAValidator for more information. + Validate(release, asset []byte) error + // Suffix describes the additional file ending which is used for finding the + // additional asset. + Suffix() string +} +``` + +#### SHA256 + +To verify the integrity by SHA256 generate a hash sum and save it within a file which has the +same naming as original file with the suffix `.sha256`. +For e.g. use sha256sum, the file `selfupdate/testdata/foo.zip.sha256` is generated with: +```shell +sha256sum foo.zip > foo.zip.sha256 +``` + +#### ECDSA +To verify the signature by ECDSA generate a signature and save it within a file which has the +same naming as original file with the suffix `.sig`. +For e.g. use openssl, the file `selfupdate/testdata/foo.zip.sig` is generated with: +```shell +openssl dgst -sha256 -sign Test.pem -out foo.zip.sig foo.zip +``` + +go-github-selfupdate makes use of go internal crypto package. Therefore the used private key +has to be compatbile with FIPS 186-3. + + + +## Development + +### Running tests + +All library sources are put in `/selfupdate` directory. So you can run tests as following +at the top of the repository: + +``` +$ go test -v ./selfupdate +``` + +Some tests are not run without setting a GitHub API token because they call GitHub API too many times. +To run them, please generate an API token and set it to an environment variable. + +``` +$ export GITHUB_TOKEN="{token generated by you}" +$ go test -v ./selfupdate +``` + +The above command runs almost all tests and it's enough to check the behavior before creating a pull request. +Some tests are still not tested because they depend on my personal API access token, though; for repositories +on GitHub Enterprise or private repositories on GitHub. + + +### Debugging + +This library can output logs for debugging. By default, logger is disabled. +You can enable the logger by the following and can know the details of the self update. + +```go +selfupdate.EnableLog() +``` + + +### CI + +Tests run on CIs (Travis CI, Appveyor) are run with the token I generated. However, because of security +reasons, it is not used for the tests for pull requests. In the tests, a GitHub API token is not set and +API rate limit is often exceeding. So please ignore the test failures on creating a pull request. + + + +## Dependencies + +This library utilizes +- [go-github][] to retrieve the information of releases +- [go-update][] to replace current binary +- [semver][] to compare versions +- [xz][] to support XZ compress format + +> Copyright (c) 2013 The go-github AUTHORS. All rights reserved. + +> Copyright 2015 Alan Shreve + +> Copyright (c) 2014 Benedikt Lang + +> Copyright (c) 2014-2016 Ulrich Kunitz + +[go-github]: https://github.com/google/go-github +[go-update]: https://github.com/inconshreveable/go-update +[semver]: https://github.com/blang/semver +[xz]: https://github.com/ulikunitz/xz + + + +## What is different from [tj/go-update][]? + +This library's goal is the same as tj/go-update, but it's different in following points. + +tj/go-update: + +- does not support Windows +- only allows `v` for version prefix +- does not ignore pre-release +- has [only a few tests](https://github.com/tj/go-update/blob/master/update_test.go) +- supports Apex store for putting releases + +[tj/go-update]: https://github.com/tj/go-update + + + +## License + +Distributed under the [MIT License](LICENSE) + +[GoDoc Badge]: https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate?status.svg +[GoDoc]: https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate +[TravisCI Status]: https://travis-ci.org/rhysd/go-github-selfupdate.svg?branch=master +[TravisCI]: https://travis-ci.org/rhysd/go-github-selfupdate +[AppVeyor Status]: https://ci.appveyor.com/api/projects/status/1tpyd9q9tw3ime5u/branch/master?svg=true +[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 diff --git a/cmd/detect-latest-release/README.md b/cmd/detect-latest-release/README.md new file mode 100644 index 0000000..86773b1 --- /dev/null +++ b/cmd/detect-latest-release/README.md @@ -0,0 +1,20 @@ +This command line tool is a small wrapper of [`selfupdate.DetectLatest()`](https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate#DetectLatest). + +Please install using `go get`. + +``` +$ go get -u github.com/rhysd/go-github-selfupdate/cmd/detect-latest-release +``` + +To know the usage, please try the command without any argument. + +``` +$ detect-latest-release +``` + +For example, following shows the latest version of [github-clone-all](https://github.com/rhysd/github-clone-all). + +``` +$ detect-latest-release rhysd/github-clone-all +``` + diff --git a/cmd/detect-latest-release/main.go b/cmd/detect-latest-release/main.go new file mode 100644 index 0000000..bf8de19 --- /dev/null +++ b/cmd/detect-latest-release/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "flag" + "fmt" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "os" + "regexp" + "strings" +) + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: detect-latest-release [flags] {repo}\n\n {repo} must be URL to GitHub repository or in 'owner/name' format.\n\nFlags:\n") + flag.PrintDefaults() +} + +func main() { + asset := flag.Bool("asset", false, "Output URL to asset") + notes := flag.Bool("release-notes", false, "Output release notes additionally") + url := flag.Bool("url", false, "Output URL for release page") + + flag.Usage = usage + flag.Parse() + + if flag.NArg() != 1 { + usage() + os.Exit(1) + } + + repo := flag.Arg(0) + if strings.HasPrefix(repo, "https://") { + repo = repo[len("https://"):] + } + if strings.HasPrefix(repo, "github.com/") { + repo = repo[len("github.com/"):] + } + + matched, err := regexp.MatchString("[^/]+/[^/]+", repo) + if err != nil { + panic(err) + } + if !matched { + usage() + os.Exit(1) + } + + latest, found, err := selfupdate.DetectLatest(repo) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if !found { + fmt.Println("No release was found") + } else { + if *asset { + fmt.Println(latest.AssetURL) + } else if *url { + fmt.Println(latest.URL) + } else { + fmt.Println(latest.Version) + if *notes { + fmt.Printf("\nRelease Notes:\n%s\n", latest.ReleaseNotes) + } + } + } +} diff --git a/cmd/go-get-release/README.md b/cmd/go-get-release/README.md new file mode 100644 index 0000000..42b36e3 --- /dev/null +++ b/cmd/go-get-release/README.md @@ -0,0 +1,29 @@ +Like `go get`, but it downloads and installs the latest release binary from GitHub instead. + +Please download a binary from [release page](https://github.com/rhysd/go-github-selfupdate/releases/tag/go-get-release) +and put it in `$PATH` or build from source with `go get`. + +``` +$ go get -u github.com/rhysd/go-github-selfupdate/cmd/go-get-release +``` + +Usage is quite similar to `go get`. But `{package}` must be hosted on GitHub. So it needs to start with `github.com/`. + +``` +$ go-get-release {package} +``` + +Please note that this command assumes that specified package is following Git tag naming rules and +released binaries naming rules described in [README](../../README.md). + +For example, following command downloads and installs the released binary of [ghr](https://github.com/tcnksm/ghr) +to `$GOPATH/bin`. + +``` +$ go-get-release github.com/tcnksm/ghr +Command was updated to the latest version 0.5.4: /Users/you/.go/bin/ghr + +$ ghr -version +ghr version v0.5.4 (a12ff1c) +``` + diff --git a/cmd/go-get-release/main.go b/cmd/go-get-release/main.go new file mode 100644 index 0000000..d4b9d5b --- /dev/null +++ b/cmd/go-get-release/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "flag" + "fmt" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "go/build" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +var version = "1.0.0" + +func usage() { + fmt.Fprintln(os.Stderr, `Usage: go-get-release [flags] {package} + + go-get-release is like "go get", but it downloads the latest release from + GitHub. {package} must start with "github.com/". + +Flags:`) + flag.PrintDefaults() +} + +func getCommand(pkg string) string { + _, cmd := filepath.Split(pkg) + if cmd == "" { + // When pkg path is ending with path separator, we need to split it out. + // i.e. github.com/rhysd/foo/cmd/bar/ + _, cmd = filepath.Split(cmd) + } + return cmd +} + +func parseSlug(pkg string) (string, bool) { + pkg = pkg[len("github.com/"):] + first := false + for i, r := range pkg { + if r == '/' { + if !first { + first = true + } else { + return pkg[:i], true + } + } + } + if first { + // When 'github.com/foo/bar' is specified, reaching here. + return pkg, true + } + return "", false +} + +func installFrom(url, cmd, path string) error { + res, err := http.Get(url) + if err != nil { + return fmt.Errorf("Failed to download release binary from %s: %s", url, err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + return fmt.Errorf("Failed to download release binary from %s: Invalid response ", url) + } + executable, err := selfupdate.UncompressCommand(res.Body, url, cmd) + if err != nil { + return fmt.Errorf("Failed to uncompress downloaded asset from %s: %s", url, err) + } + bin, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0755) + if err != nil { + return err + } + if _, err := io.Copy(bin, executable); err != nil { + return fmt.Errorf("Failed to write binary to %s: %s", path, err) + } + return nil +} + +func main() { + help := flag.Bool("help", false, "Show help") + ver := flag.Bool("version", false, "Show version") + + flag.Usage = usage + flag.Parse() + + if *ver { + fmt.Println(version) + os.Exit(0) + } + + if *help || flag.NArg() != 1 || !strings.HasPrefix(flag.Arg(0), "github.com/") { + usage() + os.Exit(1) + } + + slug, ok := parseSlug(flag.Arg(0)) + if !ok { + usage() + os.Exit(1) + } + + latest, found, err := selfupdate.DetectLatest(slug) + if err != nil { + fmt.Fprintln(os.Stderr, "Error while detecting the latest version:", err) + os.Exit(1) + } + if !found { + fmt.Fprintln(os.Stderr, "No release was found in", slug) + os.Exit(1) + } + + cmd := getCommand(flag.Arg(0)) + cmdPath := filepath.Join(build.Default.GOPATH, "bin", cmd) + if _, err := os.Stat(cmdPath); err != nil { + // When executable is not existing yet + if err := installFrom(latest.AssetURL, cmd, cmdPath); err != nil { + fmt.Fprintf(os.Stderr, "Error while installing the release binary from %s: %s\n", latest.AssetURL, err) + os.Exit(1) + } + } else { + if err := selfupdate.UpdateTo(latest.AssetURL, cmdPath); err != nil { + fmt.Fprintf(os.Stderr, "Error while replacing the binary with %s: %s\n", latest.AssetURL, err) + os.Exit(1) + } + } + + fmt.Printf(`Command was updated to the latest version %s: %s + +Release Notes: +%s +`, latest.Version, cmdPath, latest.ReleaseNotes) +} diff --git a/cmd/selfupdate-example/main.go b/cmd/selfupdate-example/main.go new file mode 100644 index 0000000..4fa3d4e --- /dev/null +++ b/cmd/selfupdate-example/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "os" +) + +const version = "1.2.3" + +func selfUpdate(slug string) error { + selfupdate.EnableLog() + + previous := semver.MustParse(version) + latest, err := selfupdate.UpdateSelf(previous, slug) + if err != nil { + return err + } + + if previous.Equals(latest.Version) { + fmt.Println("Current binary is the latest version", version) + } else { + fmt.Println("Update successfully done to version", latest.Version) + fmt.Println("Release note:\n", latest.ReleaseNotes) + } + return nil +} + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: selfupdate-example [flags]\n") + flag.PrintDefaults() +} + +func main() { + help := flag.Bool("help", false, "Show this help") + ver := flag.Bool("version", false, "Show version") + update := flag.Bool("selfupdate", false, "Try go-github-selfupdate via GitHub") + slug := flag.String("slug", "rhysd/go-github-selfupdate", "Repository of this command") + + flag.Usage = usage + flag.Parse() + + if *help { + usage() + os.Exit(0) + } + + if *ver { + fmt.Println(version) + os.Exit(0) + } + + if *update { + if err := selfUpdate(*slug); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + + usage() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7306dc9 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/rhysd/go-github-selfupdate + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/google/go-github/v30 v30.1.0 + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf + github.com/kr/pretty v0.1.0 // indirect + github.com/onsi/gomega v1.4.2 // indirect + github.com/tcnksm/go-gitconfig v0.1.2 + github.com/ulikunitz/xz v0.5.9 + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect + golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 + golang.org/x/text v0.3.5 // indirect + google.golang.org/appengine v1.3.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect +) + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3512262 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= +github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= +github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= +github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/scripts/make-release.sh b/scripts/make-release.sh new file mode 100755 index 0000000..d9c8213 --- /dev/null +++ b/scripts/make-release.sh @@ -0,0 +1,26 @@ +#! /bin/bash + +set -e + +if [ ! -d .git ]; then + echo 'Run this script from root of repository' 1>&2 + exit 1 +fi + +executable=selfupdate-example + +rm -rf release +gox -verbose ./cmd/$executable +mkdir -p release +mv selfupdate-example_* release/ +cd release +for bin in *; do + if [[ "$bin" == *windows* ]]; then + command="${executable}.exe" + else + command="$executable" + fi + mv "$bin" "$command" + zip "${bin}.zip" "$command" + rm "$command" +done diff --git a/selfupdate/detect.go b/selfupdate/detect.go new file mode 100644 index 0000000..6ca1af7 --- /dev/null +++ b/selfupdate/detect.go @@ -0,0 +1,206 @@ +package selfupdate + +import ( + "fmt" + "regexp" + "runtime" + "strings" + + "github.com/blang/semver" + "github.com/google/go-github/v30/github" +) + +var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`) + +func findAssetFromRelease(rel *github.RepositoryRelease, + suffixes []string, targetVersion string, filters []*regexp.Regexp) (*github.ReleaseAsset, semver.Version, bool) { + + if targetVersion != "" && targetVersion != rel.GetTagName() { + log.Println("Skip", rel.GetTagName(), "not matching to specified version", targetVersion) + return nil, semver.Version{}, false + } + + if targetVersion == "" && rel.GetDraft() { + log.Println("Skip draft version", rel.GetTagName()) + return nil, semver.Version{}, false + } + if targetVersion == "" && rel.GetPrerelease() { + log.Println("Skip pre-release version", rel.GetTagName()) + return nil, semver.Version{}, false + } + + verText := rel.GetTagName() + indices := reVersion.FindStringIndex(verText) + if indices == nil { + log.Println("Skip version not adopting semver", verText) + return nil, semver.Version{}, false + } + if indices[0] > 0 { + log.Println("Strip prefix of version", verText[:indices[0]], "from", verText) + verText = verText[indices[0]:] + } + + // If semver cannot parse the version text, it means that the text is not adopting + // the semantic versioning. So it should be skipped. + ver, err := semver.Make(verText) + if err != nil { + log.Println("Failed to parse a semantic version", verText) + return nil, semver.Version{}, false + } + + for _, asset := range rel.Assets { + name := asset.GetName() + if len(filters) > 0 { + // if some filters are defined, match them: if any one matches, the asset is selected + matched := false + for _, filter := range filters { + if filter.MatchString(name) { + log.Println("Selected filtered asset", name) + matched = true + break + } + log.Printf("Skipping asset %q not matching filter %v\n", name, filter) + } + if !matched { + continue + } + } + + for _, s := range suffixes { + if strings.HasSuffix(name, s) { // require version, arch etc + // default: assume single artifact + return asset, ver, true + } + } + } + + log.Println("No suitable asset was found in release", rel.GetTagName()) + return nil, semver.Version{}, false +} + +func findValidationAsset(rel *github.RepositoryRelease, validationName string) (*github.ReleaseAsset, bool) { + for _, asset := range rel.Assets { + if asset.GetName() == validationName { + return asset, true + } + } + return nil, false +} + +func findReleaseAndAsset(rels []*github.RepositoryRelease, + targetVersion string, + filters []*regexp.Regexp) (*github.RepositoryRelease, *github.ReleaseAsset, semver.Version, bool) { + // Generate candidates + suffixes := make([]string, 0, 2*7*2) + for _, sep := range []rune{'_', '-'} { + for _, ext := range []string{".zip", ".tar.gz", ".tgz", ".gzip", ".gz", ".tar.xz", ".xz", ""} { + suffix := fmt.Sprintf("%s%c%s%s", runtime.GOOS, sep, runtime.GOARCH, ext) + suffixes = append(suffixes, suffix) + if runtime.GOOS == "windows" { + suffix = fmt.Sprintf("%s%c%s.exe%s", runtime.GOOS, sep, runtime.GOARCH, ext) + suffixes = append(suffixes, suffix) + } + } + } + + var ver semver.Version + var asset *github.ReleaseAsset + var release *github.RepositoryRelease + + // Find the latest version from the list of releases. + // Returned list from GitHub API is in the order of the date when created. + // ref: https://github.com/rhysd/go-github-selfupdate/issues/11 + for _, rel := range rels { + if a, v, ok := findAssetFromRelease(rel, suffixes, targetVersion, filters); ok { + // Note: any version with suffix is less than any version without suffix. + // e.g. 0.0.1 > 0.0.1-beta + if release == nil || v.GTE(ver) { + ver = v + asset = a + release = rel + } + } + } + + if release == nil { + log.Println("Could not find any release for", runtime.GOOS, "and", runtime.GOARCH) + return nil, nil, semver.Version{}, false + } + + return release, asset, ver, true +} + +// DetectLatest tries to get the latest version of the repository on GitHub. 'slug' means 'owner/name' formatted string. +// It fetches releases information from GitHub API and find out the latest release with matching the tag names and asset names. +// Drafts and pre-releases are ignored. Assets would be suffixed by the OS name and the arch name such as 'foo_linux_amd64' +// where 'foo' is a command name. '-' can also be used as a separator. File can be compressed with zip, gzip, zxip, tar&zip or tar&zxip. +// So the asset can have a file extension for the corresponding compression format such as '.zip'. +// On Windows, '.exe' also can be contained such as 'foo_windows_amd64.exe.zip'. +func (up *Updater) DetectLatest(slug string) (release *Release, found bool, err error) { + return up.DetectVersion(slug, "") +} + +// DetectVersion tries to get the given version of the repository on Github. `slug` means `owner/name` formatted string. +// And version indicates the required version. +func (up *Updater) DetectVersion(slug string, version string) (release *Release, found bool, err error) { + repo := strings.Split(slug, "/") + if len(repo) != 2 || repo[0] == "" || repo[1] == "" { + return nil, false, fmt.Errorf("Invalid slug format. It should be 'owner/name': %s", slug) + } + + 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 { + // 404 means repository not found or release not found. It's not an error here. + err = nil + log.Println("API returned 404. Repository or release not found") + } + return nil, false, err + } + + rel, asset, ver, found := findReleaseAndAsset(rels, version, up.filters) + if !found { + return nil, false, nil + } + + url := asset.GetBrowserDownloadURL() + log.Println("Successfully fetched the latest release. tag:", rel.GetTagName(), ", name:", rel.GetName(), ", URL:", rel.GetURL(), ", Asset:", url) + + publishedAt := rel.GetPublishedAt().Time + release = &Release{ + ver, + url, + asset.GetSize(), + asset.GetID(), + -1, + rel.GetHTMLURL(), + rel.GetBody(), + rel.GetName(), + &publishedAt, + repo[0], + repo[1], + } + + if up.validator != nil { + validationName := asset.GetName() + up.validator.Suffix() + validationAsset, ok := findValidationAsset(rel, validationName) + if !ok { + return nil, false, fmt.Errorf("Failed finding validation file %q", validationName) + } + release.ValidationAssetID = validationAsset.GetID() + } + + return release, true, nil +} + +// DetectLatest detects the latest release of the slug (owner/repo). +// This function is a shortcut version of updater.DetectLatest() method. +func DetectLatest(slug string) (*Release, bool, error) { + return DefaultUpdater().DetectLatest(slug) +} + +// DetectVersion detects the given release of the slug (owner/repo) from its version. +func DetectVersion(slug string, version string) (*Release, bool, error) { + return DefaultUpdater().DetectVersion(slug, version) +} diff --git a/selfupdate/detect_test.go b/selfupdate/detect_test.go new file mode 100644 index 0000000..d64fe65 --- /dev/null +++ b/selfupdate/detect_test.go @@ -0,0 +1,457 @@ +package selfupdate + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + + "github.com/blang/semver" + "github.com/google/go-github/v30/github" +) + +func TestDetectReleaseWithVersionPrefix(t *testing.T) { + r, ok, err := DetectLatest("rhysd/github-clone-all") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatal("Failed to detect latest") + } + if r == nil { + t.Fatal("Release detected but nil returned for it") + } + if r.Version.LE(semver.MustParse("2.0.0")) { + t.Error("Incorrect version:", r.Version) + } + if !strings.HasSuffix(r.AssetURL, ".zip") && !strings.HasSuffix(r.AssetURL, ".tar.gz") { + t.Error("Incorrect URL for asset:", r.AssetURL) + } + if r.URL == "" { + t.Error("Document URL should not be empty") + } + if r.ReleaseNotes == "" { + t.Error("Description should not be empty for this repo") + } + if r.Name == "" { + t.Error("Release name is unexpectedly empty") + } + 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 TestDetectVersionExisting(t *testing.T) { + testVersion := "v2.2.0" + r, ok, err := DetectVersion("rhysd/github-clone-all", testVersion) + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatalf("Failed to detect %s", testVersion) + } + if r == nil { + t.Fatal("Release detected but nil returned for it") + } +} + +func TestDetectVersionNotExisting(t *testing.T) { + r, ok, err := DetectVersion("rhysd/github-clone-all", "foobar") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if ok { + t.Fatal("Failed to correctly detect foobar") + } + if r != nil { + t.Fatal("Release not detected but got a returned value for it") + } +} + +func TestDetectReleasesForVariousArchives(t *testing.T) { + for _, tc := range []struct { + slug string + prefix string + }{ + {"rhysd-test/test-release-zip", "v"}, + {"rhysd-test/test-release-tar", "v"}, + {"rhysd-test/test-release-gzip", "v"}, + {"rhysd-test/test-release-xz", "release-v"}, + {"rhysd-test/test-release-tar-xz", "release-"}, + } { + t.Run(tc.slug, func(t *testing.T) { + r, ok, err := DetectLatest(tc.slug) + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatal(tc.slug, "not found") + } + if r == nil { + t.Fatal("Release not detected") + } + if !r.Version.Equals(semver.MustParse("1.2.3")) { + t.Error("") + } + url := fmt.Sprintf("https://github.com/%s/releases/tag/%s1.2.3", tc.slug, tc.prefix) + if r.URL != url { + t.Error("URL is not correct. Want", url, "but got", r.URL) + } + if r.ReleaseNotes == "" { + t.Error("Release note is unexpectedly empty") + } + if !strings.HasPrefix(r.AssetURL, fmt.Sprintf("https://github.com/%s/releases/download/%s1.2.3/", tc.slug, tc.prefix)) { + t.Error("Unexpected asset URL:", r.AssetURL) + } + if r.Name == "" { + t.Error("Release name is unexpectedly empty") + } + 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) + } + }) + } +} + +func TestDetectReleaseButNoAsset(t *testing.T) { + _, ok, err := DetectLatest("rhysd/clever-f.vim") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if ok { + t.Fatal("When no asset found, result should be marked as 'not found'") + } +} + +func TestDetectNoRelease(t *testing.T) { + _, ok, err := DetectLatest("rhysd/clever-f.vim") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if ok { + t.Fatal("When no release found, result should be marked as 'not found'") + } +} + +func TestInvalidSlug(t *testing.T) { + up := DefaultUpdater() + + for _, slug := range []string{ + "foo", + "/", + "foo/", + "/bar", + "foo/bar/piyo", + } { + _, _, err := up.DetectLatest(slug) + if err == nil { + t.Error(slug, "should be invalid slug") + } + if !strings.Contains(err.Error(), "Invalid slug format") { + t.Error("Unexpected error for", slug, ":", err) + } + } +} + +func TestNonExistingRepo(t *testing.T) { + v, ok, err := DetectLatest("rhysd/non-existing-repo") + if err != nil { + t.Fatal("Non-existing repo should not cause an error:", v) + } + if ok { + t.Fatal("Release for non-existing repo should not be found") + } +} + +func TestNoReleaseFound(t *testing.T) { + _, ok, err := DetectLatest("rhysd/misc") + if err != nil { + t.Fatal("Repo having no release should not cause an error:", err) + } + if ok { + 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("") + } +} + +func TestFindReleaseAndAsset(t *testing.T) { + EnableLog() + type findReleaseAndAssetFixture struct { + name string + rels *github.RepositoryRelease + targetVersion string + filters []*regexp.Regexp + expectedAsset string + expectedVersion string + expectedFound bool + } + + rel1 := "rel1" + v1 := "1.0.0" + rel11 := "rel11" + v11 := "1.1.0" + asset1 := "asset1.gz" + asset2 := "asset2.gz" + wrongAsset1 := "asset1.yaml" + asset11 := "asset11.gz" + url1 := "https://asset1" + url2 := "https://asset2" + url11 := "https://asset11" + for _, fixture := range []findReleaseAndAssetFixture{ + { + name: "empty fixture", + rels: nil, + targetVersion: "", + filters: nil, + expectedFound: false, + }, + { + name: "find asset, no filters", + rels: &github.RepositoryRelease{ + Name: &rel1, + TagName: &v1, + Assets: []*github.ReleaseAsset{ + { + Name: &asset1, + URL: &url1, + }, + }, + }, + targetVersion: "1.0.0", + expectedAsset: asset1, + expectedVersion: "1.0.0", + expectedFound: true, + }, + { + name: "don't find asset with wrong extension, no filters", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &wrongAsset1, + URL: &url11, + }, + }, + }, + targetVersion: "1.1.0", + expectedFound: false, + }, + { + name: "find asset with different name, no filters", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset1, + URL: &url11, + }, + }, + }, + targetVersion: "1.1.0", + expectedAsset: asset1, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, no filters (2)", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + }, + }, + targetVersion: "1.1.0", + expectedAsset: asset11, + expectedVersion: "1.1.0", + filters: nil, + expectedFound: true, + }, + { + name: "find asset, match filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset1, + URL: &url1, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{regexp.MustCompile("11")}, + expectedAsset: asset11, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, match another filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset1, + URL: &url1, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{regexp.MustCompile("([^1])1{1}([^1])")}, + expectedAsset: asset1, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, match any filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset2, + URL: &url2, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{ + regexp.MustCompile("([^1])1{1}([^1])"), + regexp.MustCompile("([^1])2{1}([^1])"), + }, + expectedAsset: asset2, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, match no filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset2, + URL: &url2, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{ + regexp.MustCompile("another"), + regexp.MustCompile("binary"), + }, + expectedFound: false, + }, + } { + asset, ver, found := findAssetFromRelease(fixture.rels, []string{".gz"}, fixture.targetVersion, fixture.filters) + if fixture.expectedFound { + if !found { + t.Errorf("expected to find an asset for this fixture: %q", fixture.name) + continue + } + if asset.Name == nil { + t.Errorf("invalid asset struct returned from fixture: %q, got: %v", fixture.name, asset) + continue + } + if *asset.Name != fixture.expectedAsset { + t.Errorf("expected asset %q in fixture: %q, got: %s", fixture.expectedAsset, fixture.name, *asset.Name) + continue + } + t.Logf("asset %v, %v", asset, ver) + } else if found { + t.Errorf("expected not to find an asset for this fixture: %q, but got: %v", fixture.name, asset) + } + } + +} diff --git a/selfupdate/doc.go b/selfupdate/doc.go new file mode 100644 index 0000000..38fc167 --- /dev/null +++ b/selfupdate/doc.go @@ -0,0 +1,38 @@ +/* +Package selfupdate provides self-update mechanism to Go command line tools. + +Go does not provide the way to install/update the stable version of tools. By default, Go command line tools are updated + +- using `go get -u` (updating to HEAD) +- using system's package manager (depending on the platform) +- downloading executables from GitHub release page manually + +By using this library, you will get 4th choice: + +- from your command line tool directly (and automatically) + +go-github-selfupdate detects the information of the latest release via GitHub Releases API and check the current version. +If newer version than itself is detected, it downloads released binary from GitHub and replaces itself. + +- Automatically detects the latest version of released binary on GitHub +- Retrieve the proper binary for the OS and arch where the binary is running +- Update the binary with rollback support on failure +- Tested on Linux, macOS and Windows +- Many archive and compression formats are supported (zip, gzip, xzip, tar) + +There are some naming rules. Please read following links. + +Naming Rules of Released Binaries: + https://github.com/rhysd/go-github-selfupdate#naming-rules-of-released-binaries + +Naming Rules of Git Tags: + https://github.com/rhysd/go-github-selfupdate#naming-rules-of-git-tags + +This package is hosted on GitHub: + https://github.com/rhysd/go-github-selfupdate + +Small CLI tools as wrapper of this library are available also: + https://github.com/rhysd/go-github-selfupdate/cmd/detect-latest-release + https://github.com/rhysd/go-github-selfupdate/cmd/go-get-release +*/ +package selfupdate diff --git a/selfupdate/e2e_test.go b/selfupdate/e2e_test.go new file mode 100644 index 0000000..6ae1481 --- /dev/null +++ b/selfupdate/e2e_test.go @@ -0,0 +1,22 @@ +package selfupdate + +import ( + "os" + "os/exec" + "testing" +) + +func TestRunSelfUpdateExample(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + + t.Skip("TODO") + + if err := exec.Command("go", "build", "../cmd/selfupdate-example").Run(); err != nil { + t.Fatal(err) + } + defer os.Remove("selfupdate-example") + + // TODO +} diff --git a/selfupdate/log.go b/selfupdate/log.go new file mode 100644 index 0000000..7c5e7ac --- /dev/null +++ b/selfupdate/log.go @@ -0,0 +1,30 @@ +package selfupdate + +import ( + "io/ioutil" + stdlog "log" + "os" +) + +var log = stdlog.New(ioutil.Discard, "", 0) +var logEnabled = false + +// EnableLog enables to output logging messages in library +func EnableLog() { + if logEnabled { + return + } + logEnabled = true + log.SetOutput(os.Stderr) + log.SetFlags(stdlog.Ltime) +} + +// DisableLog disables to output logging messages in library +func DisableLog() { + if !logEnabled { + return + } + logEnabled = false + log.SetOutput(ioutil.Discard) + log.SetFlags(0) +} diff --git a/selfupdate/log_test.go b/selfupdate/log_test.go new file mode 100644 index 0000000..59f37b4 --- /dev/null +++ b/selfupdate/log_test.go @@ -0,0 +1,30 @@ +package selfupdate + +import ( + "testing" +) + +func TestEnableDisableLog(t *testing.T) { + defer DisableLog() + + EnableLog() + if !logEnabled { + t.Fatal("Log should be enabled") + } + EnableLog() + if !logEnabled { + t.Fatal("Log should be enabled") + } + DisableLog() + if logEnabled { + t.Fatal("Log should not be enabled") + } + DisableLog() + if logEnabled { + t.Fatal("Log should not be enabled") + } + EnableLog() + if !logEnabled { + t.Fatal("Log should be enabled") + } +} diff --git a/selfupdate/release.go b/selfupdate/release.go new file mode 100644 index 0000000..014ac47 --- /dev/null +++ b/selfupdate/release.go @@ -0,0 +1,33 @@ +package selfupdate + +import ( + "time" + + "github.com/blang/semver" +) + +// Release represents a release asset for current OS and arch. +type Release struct { + // Version is the version of the release + Version semver.Version + // AssetURL is a URL to the uploaded file for the release + AssetURL string + // AssetSize represents the size of asset in bytes + AssetByteSize int + // AssetID is the ID of the asset on GitHub + AssetID int64 + // ValidationAssetID is the ID of additional validaton asset on GitHub + ValidationAssetID int64 + // URL is a URL to release page for browsing + URL string + // ReleaseNotes is a release notes of the release + ReleaseNotes string + // Name represents a name of the release + 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 +} diff --git a/selfupdate/testdata/Test.crt b/selfupdate/testdata/Test.crt new file mode 100644 index 0000000..7f4eff3 --- /dev/null +++ b/selfupdate/testdata/Test.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU +ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD +EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A +QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX +rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh +AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ +7cugOPws7/OoUo1124LKPugISg== +-----END CERTIFICATE----- diff --git a/selfupdate/testdata/Test.pem b/selfupdate/testdata/Test.pem new file mode 100644 index 0000000..c240365 --- /dev/null +++ b/selfupdate/testdata/Test.pem @@ -0,0 +1,14 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJvTkRedVrQDNjCb9/RfVjzRwz8S059Y1J6w2N8gy8jVoAoGCCqGSM49 +AwEHoUQDQgAEs8fjo/Mi5A3c2v2YxV6AQPJnr70qYMEpsmqn0BTcI8RhZUgB46tW +qeDYdO15yQKbZjfI/dr0fvS21jyW0GSXrA== +-----END EC PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU +ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD +EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A +QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX +rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh +AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ +7cugOPws7/OoUo1124LKPugISg== +-----END CERTIFICATE----- diff --git a/selfupdate/testdata/bar-not-found.gzip b/selfupdate/testdata/bar-not-found.gzip new file mode 100644 index 0000000..05e1b20 Binary files /dev/null and b/selfupdate/testdata/bar-not-found.gzip differ diff --git a/selfupdate/testdata/bar-not-found.tar.gz b/selfupdate/testdata/bar-not-found.tar.gz new file mode 100644 index 0000000..1b1a339 Binary files /dev/null and b/selfupdate/testdata/bar-not-found.tar.gz differ diff --git a/selfupdate/testdata/bar-not-found.tar.xz b/selfupdate/testdata/bar-not-found.tar.xz new file mode 100644 index 0000000..25116ef Binary files /dev/null and b/selfupdate/testdata/bar-not-found.tar.xz differ diff --git a/selfupdate/testdata/bar-not-found.zip b/selfupdate/testdata/bar-not-found.zip new file mode 100644 index 0000000..d2b1a88 Binary files /dev/null and b/selfupdate/testdata/bar-not-found.zip differ diff --git a/selfupdate/testdata/empty.tar.gz b/selfupdate/testdata/empty.tar.gz new file mode 100644 index 0000000..959fb3e Binary files /dev/null and b/selfupdate/testdata/empty.tar.gz differ diff --git a/selfupdate/testdata/empty.zip b/selfupdate/testdata/empty.zip new file mode 100644 index 0000000..afdaf0c Binary files /dev/null and b/selfupdate/testdata/empty.zip differ diff --git a/selfupdate/testdata/fake-executable b/selfupdate/testdata/fake-executable new file mode 100644 index 0000000..2fcb8d1 --- /dev/null +++ b/selfupdate/testdata/fake-executable @@ -0,0 +1 @@ +this file is used for passing check of file existence in update tests. diff --git a/selfupdate/testdata/fake-executable.exe b/selfupdate/testdata/fake-executable.exe new file mode 100644 index 0000000..2fcb8d1 --- /dev/null +++ b/selfupdate/testdata/fake-executable.exe @@ -0,0 +1 @@ +this file is used for passing check of file existence in update tests. diff --git a/selfupdate/testdata/foo.tar.gz b/selfupdate/testdata/foo.tar.gz new file mode 100644 index 0000000..b2f972f Binary files /dev/null and b/selfupdate/testdata/foo.tar.gz differ diff --git a/selfupdate/testdata/foo.tar.xz b/selfupdate/testdata/foo.tar.xz new file mode 100644 index 0000000..c4b3dd1 Binary files /dev/null and b/selfupdate/testdata/foo.tar.xz differ diff --git a/selfupdate/testdata/foo.tgz b/selfupdate/testdata/foo.tgz new file mode 100644 index 0000000..b2f972f Binary files /dev/null and b/selfupdate/testdata/foo.tgz differ diff --git a/selfupdate/testdata/foo.zip b/selfupdate/testdata/foo.zip new file mode 100644 index 0000000..c0f5674 Binary files /dev/null and b/selfupdate/testdata/foo.zip differ diff --git a/selfupdate/testdata/foo.zip.sha256 b/selfupdate/testdata/foo.zip.sha256 new file mode 100644 index 0000000..9a3192d --- /dev/null +++ b/selfupdate/testdata/foo.zip.sha256 @@ -0,0 +1 @@ +e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip diff --git a/selfupdate/testdata/foo.zip.sig b/selfupdate/testdata/foo.zip.sig new file mode 100644 index 0000000..03d06a6 Binary files /dev/null and b/selfupdate/testdata/foo.zip.sig differ diff --git a/selfupdate/testdata/github-release-test/main.go b/selfupdate/testdata/github-release-test/main.go new file mode 100644 index 0000000..d4ee4c4 --- /dev/null +++ b/selfupdate/testdata/github-release-test/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + println("not released yet!") +} diff --git a/selfupdate/testdata/invalid-gzip.tar.gz b/selfupdate/testdata/invalid-gzip.tar.gz new file mode 100644 index 0000000..e69de29 diff --git a/selfupdate/testdata/invalid-tar.tar.gz b/selfupdate/testdata/invalid-tar.tar.gz new file mode 100644 index 0000000..692bf2c Binary files /dev/null and b/selfupdate/testdata/invalid-tar.tar.gz differ diff --git a/selfupdate/testdata/invalid-tar.tar.xz b/selfupdate/testdata/invalid-tar.tar.xz new file mode 100644 index 0000000..2559412 Binary files /dev/null and b/selfupdate/testdata/invalid-tar.tar.xz differ diff --git a/selfupdate/testdata/invalid-xz.tar.xz b/selfupdate/testdata/invalid-xz.tar.xz new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/selfupdate/testdata/invalid-xz.tar.xz @@ -0,0 +1 @@ +hello diff --git a/selfupdate/testdata/invalid.gz b/selfupdate/testdata/invalid.gz new file mode 100644 index 0000000..e69de29 diff --git a/selfupdate/testdata/invalid.xz b/selfupdate/testdata/invalid.xz new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/selfupdate/testdata/invalid.xz @@ -0,0 +1 @@ +hello diff --git a/selfupdate/testdata/invalid.zip b/selfupdate/testdata/invalid.zip new file mode 100644 index 0000000..e69de29 diff --git a/selfupdate/testdata/single-file.gz b/selfupdate/testdata/single-file.gz new file mode 100644 index 0000000..a4766d6 Binary files /dev/null and b/selfupdate/testdata/single-file.gz differ diff --git a/selfupdate/testdata/single-file.gzip b/selfupdate/testdata/single-file.gzip new file mode 100644 index 0000000..5a5411c Binary files /dev/null and b/selfupdate/testdata/single-file.gzip differ diff --git a/selfupdate/testdata/single-file.xz b/selfupdate/testdata/single-file.xz new file mode 100644 index 0000000..a4fb5cb Binary files /dev/null and b/selfupdate/testdata/single-file.xz differ diff --git a/selfupdate/testdata/single-file.zip b/selfupdate/testdata/single-file.zip new file mode 100644 index 0000000..838a01c Binary files /dev/null and b/selfupdate/testdata/single-file.zip differ diff --git a/selfupdate/uncompress.go b/selfupdate/uncompress.go new file mode 100644 index 0000000..0449ec9 --- /dev/null +++ b/selfupdate/uncompress.go @@ -0,0 +1,136 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "github.com/ulikunitz/xz" + "io" + "io/ioutil" + "path/filepath" + "runtime" + "strings" +) + +func matchExecutableName(cmd, target string) bool { + if cmd == target { + return true + } + + o, a := runtime.GOOS, runtime.GOARCH + + // When the contained executable name is full name (e.g. foo_darwin_amd64), + // it is also regarded as a target executable file. (#19) + for _, d := range []rune{'_', '-'} { + c := fmt.Sprintf("%s%c%s%c%s", cmd, d, o, d, a) + if o == "windows" { + c += ".exe" + } + if c == target { + return true + } + } + + return false +} + +func unarchiveTar(src io.Reader, url, cmd string) (io.Reader, error) { + t := tar.NewReader(src) + for { + h, err := t.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("Failed to unarchive .tar file: %s", err) + } + _, name := filepath.Split(h.Name) + if matchExecutableName(cmd, name) { + log.Println("Executable file", h.Name, "was found in tar archive") + return t, nil + } + } + + return nil, fmt.Errorf("File '%s' for the command is not found in %s", cmd, url) +} + +// UncompressCommand uncompresses the given source. Archive and compression format is +// automatically detected from 'url' parameter, which represents the URL of asset. +// This returns a reader for the uncompressed command given by 'cmd'. '.zip', +// '.tar.gz', '.tar.xz', '.tgz', '.gz' and '.xz' are supported. +func UncompressCommand(src io.Reader, url, cmd string) (io.Reader, error) { + if strings.HasSuffix(url, ".zip") { + log.Println("Uncompressing zip file", url) + + // Zip format requires its file size for uncompressing. + // So we need to read the HTTP response into a buffer at first. + buf, err := ioutil.ReadAll(src) + if err != nil { + return nil, fmt.Errorf("Failed to create buffer for zip file: %s", err) + } + + r := bytes.NewReader(buf) + z, err := zip.NewReader(r, r.Size()) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress zip file: %s", err) + } + + for _, file := range z.File { + _, name := filepath.Split(file.Name) + if !file.FileInfo().IsDir() && matchExecutableName(cmd, name) { + log.Println("Executable file", file.Name, "was found in zip archive") + return file.Open() + } + } + + return nil, fmt.Errorf("File '%s' for the command is not found in %s", cmd, url) + } else if strings.HasSuffix(url, ".tar.gz") || strings.HasSuffix(url, ".tgz") { + log.Println("Uncompressing tar.gz file", url) + + gz, err := gzip.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress .tar.gz file: %s", err) + } + + return unarchiveTar(gz, url, cmd) + } else if strings.HasSuffix(url, ".gzip") || strings.HasSuffix(url, ".gz") { + log.Println("Uncompressing gzip file", url) + + r, err := gzip.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress gzip file downloaded from %s: %s", url, err) + } + + name := r.Header.Name + if !matchExecutableName(cmd, name) { + return nil, fmt.Errorf("File name '%s' does not match to command '%s' found in %s", name, cmd, url) + } + + log.Println("Executable file", name, "was found in gzip file") + return r, nil + } else if strings.HasSuffix(url, ".tar.xz") { + log.Println("Uncompressing tar.xz file", url) + + xzip, err := xz.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress .tar.xz file: %s", err) + } + + return unarchiveTar(xzip, url, cmd) + } else if strings.HasSuffix(url, ".xz") { + log.Println("Uncompressing xzip file", url) + + xzip, err := xz.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress xzip file downloaded from %s: %s", url, err) + } + + log.Println("Uncompressed file from xzip is assumed to be an executable", cmd) + return xzip, nil + } + + log.Println("Uncompression is not needed", url) + return src, nil +} diff --git a/selfupdate/uncompress_test.go b/selfupdate/uncompress_test.go new file mode 100644 index 0000000..12bb812 --- /dev/null +++ b/selfupdate/uncompress_test.go @@ -0,0 +1,133 @@ +package selfupdate + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompressionNotRequired(t *testing.T) { + buf := []byte{'a', 'b', 'c'} + want := bytes.NewReader(buf) + r, err := UncompressCommand(want, "https://github.com/foo/bar/releases/download/v1.2.3/foo", "foo") + if err != nil { + t.Fatal(err) + } + have, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + for i, b := range have { + if buf[i] != b { + t.Error(i, "th elem is not the same as wanted. want", buf[i], "but got", b) + } + } +} + +func getArchiveFileExt(file string) string { + if strings.HasSuffix(file, ".tar.gz") { + return ".tar.gz" + } + if strings.HasSuffix(file, ".tar.xz") { + return ".tar.xz" + } + return filepath.Ext(file) +} + +func TestUncompress(t *testing.T) { + for _, n := range []string{ + "testdata/foo.zip", + "testdata/single-file.zip", + "testdata/single-file.gz", + "testdata/single-file.gzip", + "testdata/foo.tar.gz", + "testdata/foo.tgz", + "testdata/foo.tar.xz", + "testdata/single-file.xz", + } { + t.Run(n, func(t *testing.T) { + f, err := os.Open(n) + if err != nil { + t.Fatal(err) + } + + ext := getArchiveFileExt(n) + url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext + r, err := UncompressCommand(f, url, "bar") + if err != nil { + t.Fatal(err) + } + + bytes, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + s := string(bytes) + if s != "this is test\n" { + t.Fatal("Uncompressing zip failed into unexpected content", s) + } + }) + } +} + +func TestUncompressInvalidArchive(t *testing.T) { + for _, a := range []struct { + name string + msg string + }{ + {"testdata/invalid.zip", "not a valid zip file"}, + {"testdata/invalid.gz", "Failed to uncompress gzip file"}, + {"testdata/invalid-tar.tar.gz", "Failed to unarchive .tar file"}, + {"testdata/invalid-gzip.tar.gz", "Failed to uncompress .tar.gz file"}, + {"testdata/invalid.xz", "Failed to uncompress xzip file"}, + {"testdata/invalid-tar.tar.xz", "Failed to unarchive .tar file"}, + {"testdata/invalid-xz.tar.xz", "Failed to uncompress .tar.xz file"}, + } { + f, err := os.Open(a.name) + if err != nil { + t.Fatal(err) + } + + ext := getArchiveFileExt(a.name) + url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext + _, err = UncompressCommand(f, url, "bar") + if err == nil { + t.Fatal("Error should be raised") + } + if !strings.Contains(err.Error(), a.msg) { + t.Fatal("Unexpected error:", err) + } + } +} + +func TestTargetNotFound(t *testing.T) { + for _, tc := range []struct { + name string + msg string + }{ + {"testdata/empty.zip", "command is not found"}, + {"testdata/bar-not-found.zip", "command is not found"}, + {"testdata/bar-not-found.gzip", "does not match to command"}, + {"testdata/empty.tar.gz", "command is not found"}, + {"testdata/bar-not-found.tar.gz", "command is not found"}, + } { + t.Run(tc.name, func(t *testing.T) { + f, err := os.Open(tc.name) + if err != nil { + t.Fatal(err) + } + ext := getArchiveFileExt(tc.name) + url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext + _, err = UncompressCommand(f, url, "bar") + if err == nil { + t.Fatal("Error should be raised for") + } + if !strings.Contains(err.Error(), tc.msg) { + t.Fatal("Unexpected error:", err) + } + }) + } +} diff --git a/selfupdate/update.go b/selfupdate/update.go new file mode 100644 index 0000000..1ae0d7d --- /dev/null +++ b/selfupdate/update.go @@ -0,0 +1,181 @@ +package selfupdate + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/blang/semver" + "github.com/inconshreveable/go-update" +) + +func uncompressAndUpdate(src io.Reader, assetURL, cmdPath string) error { + _, cmd := filepath.Split(cmdPath) + asset, err := UncompressCommand(src, assetURL, cmd) + if err != nil { + return err + } + + log.Println("Will update", cmdPath, "to the latest downloaded from", assetURL) + return update.Apply(asset, update.Options{ + TargetPath: cmdPath, + }) +} + +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 { + var client http.Client + src, redirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.AssetID, &client) + 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 + } + } + defer src.Close() + + data, err := ioutil.ReadAll(src) + if err != nil { + return fmt.Errorf("Failed reading asset body: %v", err) + } + + if up.validator == nil { + return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath) + } + + validationSrc, validationRedirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.ValidationAssetID, &client) + if err != nil { + return fmt.Errorf("Failed to call GitHub Releases API for getting an validation asset(ID: %d) for repository '%s/%s': %s", rel.ValidationAssetID, rel.RepoOwner, rel.RepoName, err) + } + if validationRedirectURL != "" { + log.Println("Redirect URL was returned while trying to download a release validation asset from GitHub API. Falling back to downloading from asset URL directly:", redirectURL) + validationSrc, err = up.downloadDirectlyFromURL(validationRedirectURL) + if err != nil { + return err + } + } + + defer validationSrc.Close() + + validationData, err := ioutil.ReadAll(validationSrc) + if err != nil { + return fmt.Errorf("Failed reading validation asset body: %v", err) + } + + if err := up.validator.Validate(data, validationData); err != nil { + return fmt.Errorf("Failed validating asset content: %v", err) + } + + return uncompressAndUpdate(bytes.NewReader(data), 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 (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" + } + + stat, err := os.Lstat(cmdPath) + if err != nil { + return nil, fmt.Errorf("Failed to stat '%s'. File may not exist: %s", cmdPath, err) + } + if stat.Mode()&os.ModeSymlink != 0 { + p, err := filepath.EvalSymlinks(cmdPath) + if err != nil { + return nil, fmt.Errorf("Failed to resolve symlink '%s' for executable: %s", cmdPath, err) + } + cmdPath = p + } + + rel, ok, err := up.DetectLatest(slug) + if err != nil { + return nil, err + } + if !ok { + log.Println("No release detected. Current version is considered up-to-date") + return &Release{Version: current}, nil + } + if current.Equals(rel.Version) { + log.Println("Current version", current, "is the latest. Update is not needed") + return rel, nil + } + log.Println("Will update", cmdPath, "to the latest version", rel.Version) + if err := up.UpdateTo(rel, cmdPath); err != nil { + return nil, err + } + return rel, nil +} + +// UpdateSelf updates the running executable itself to the latest version. +// 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. +func (up *Updater) UpdateSelf(current semver.Version, slug string) (*Release, error) { + cmdPath, err := os.Executable() + if err != nil { + return nil, err + } + return up.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 + } + defer src.Close() + 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) +} diff --git a/selfupdate/update_test.go b/selfupdate/update_test.go new file mode 100644 index 0000000..7dfcef2 --- /dev/null +++ b/selfupdate/update_test.go @@ -0,0 +1,353 @@ +package selfupdate + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/blang/semver" +) + +func setupTestBinary() { + if err := exec.Command("go", "build", "./testdata/github-release-test/").Run(); err != nil { + panic(err) + } +} + +func teardownTestBinary() { + bin := "github-release-test" + if runtime.GOOS == "windows" { + bin = "github-release-test.exe" + } + if err := os.Remove(bin); err != nil { + panic(err) + } +} + +func TestUpdateCommand(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + + for _, slug := range []string{ + "rhysd-test/test-release-zip", + "rhysd-test/test-release-tar", + "rhysd-test/test-release-gzip", + "rhysd-test/test-release-tar-xz", + "rhysd-test/test-release-xz", + "rhysd-test/test-release-contain-version", + } { + t.Run(slug, func(t *testing.T) { + setupTestBinary() + defer teardownTestBinary() + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := UpdateCommand("github-release-test", prev, slug) + 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) + } + }) + } +} + +func TestUpdateViaSymlink(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + if runtime.GOOS == "windows" && os.Getenv("APPVEYOR") == "" { + t.Skip("skipping because creating symlink on windows requires the root privilege") + } + + setupTestBinary() + defer teardownTestBinary() + exePath := "github-release-test" + symPath := "github-release-test-sym" + if runtime.GOOS == "windows" { + exePath = "github-release-test.exe" + symPath = "github-release-test-sym.exe" + } + if err := os.Symlink(exePath, symPath); err != nil { + t.Fatal(err) + } + defer os.Remove(symPath) + + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := UpdateCommand(symPath, prev, "rhysd-test/test-release-zip") + if err != nil { + t.Fatal(err) + } + if rel.Version.NE(latest) { + t.Error("Version is not latest", rel.Version) + } + + // Test not symbolic link, but actual physical executable + 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) + } + + s, err := os.Lstat(symPath) + if err != nil { + t.Fatal(err) + } + if s.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is not a symlink.", symPath) + } + p, err := filepath.EvalSymlinks(symPath) + if err != nil { + t.Fatal(err) + } + if p != exePath { + t.Fatal("Created symlink no loger points the executable:", p) + } +} + +func TestUpdateBrokenSymlinks(t *testing.T) { + if runtime.GOOS == "windows" && os.Getenv("APPVEYOR") == "" { + t.Skip("skipping because creating symlink on windows requires the root privilege") + } + + // unknown-xxx -> unknown-yyy -> {not existing} + xxx := "unknown-xxx" + yyy := "unknown-yyy" + if runtime.GOOS == "windows" { + xxx = "unknown-xxx.exe" + yyy = "unknown-yyy.exe" + } + if err := os.Symlink("not-existing", yyy); err != nil { + t.Fatal(err) + } + defer os.Remove(yyy) + if err := os.Symlink(yyy, xxx); err != nil { + t.Fatal(err) + } + defer os.Remove(xxx) + + v := semver.MustParse("1.2.2") + for _, p := range []string{yyy, xxx} { + _, err := UpdateCommand(p, v, "owner/repo") + if err == nil { + t.Fatal("Error should occur for unlinked symlink", p) + } + if !strings.Contains(err.Error(), "Failed to resolve symlink") { + t.Fatal("Unexpected error for broken symlink", p, err) + } + } +} + +func TestNotExistingCommandPath(t *testing.T) { + _, err := UpdateCommand("not-existing-command-path", semver.MustParse("1.2.2"), "owner/repo") + if err == nil { + t.Fatal("Not existing command path should cause an error") + } + if !strings.Contains(err.Error(), "File may not exist") { + t.Fatal("Unexpected error for not existing command path", err) + } +} + +func TestNoReleaseFoundForUpdate(t *testing.T) { + v := semver.MustParse("1.0.0") + fake := filepath.FromSlash("./testdata/fake-executable") + rel, err := UpdateCommand(fake, v, "rhysd/misc") + if err != nil { + t.Fatal("No release should not make an error:", err) + } + if rel.Version.NE(v) { + t.Error("No release should return the current version as the latest:", rel.Version) + } + if rel.URL != "" { + t.Error("Browse URL should be empty when no release found:", rel.URL) + } + if rel.AssetURL != "" { + t.Error("Asset URL should be empty when no release found:", rel.AssetURL) + } + if rel.ReleaseNotes != "" { + t.Error("Release notes should be empty when no release found:", rel.ReleaseNotes) + } +} + +func TestCurrentIsTheLatest(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + setupTestBinary() + defer teardownTestBinary() + + v := semver.MustParse("1.2.3") + rel, err := UpdateCommand("github-release-test", v, "rhysd-test/test-release-zip") + if err != nil { + t.Fatal(err) + } + if rel.Version.NE(v) { + t.Error("v1.2.3 should be the latest:", rel.Version) + } + if rel.URL == "" { + t.Error("Browse URL should not be empty when release found:", rel.URL) + } + if rel.AssetURL == "" { + t.Error("Asset URL should not be empty when release found:", rel.AssetURL) + } + if rel.ReleaseNotes == "" { + t.Error("Release notes should not be empty when release found:", rel.ReleaseNotes) + } +} + +func TestBrokenBinaryUpdate(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + + fake := filepath.FromSlash("./testdata/fake-executable") + _, err := UpdateCommand(fake, semver.MustParse("1.2.2"), "rhysd-test/test-incorrect-release") + if err == nil { + t.Fatal("Error should occur for broken package") + } + if !strings.Contains(err.Error(), "Failed to uncompress .tar.gz file") { + t.Fatal("Unexpected error:", err) + } +} + +func TestInvalidSlugForUpdate(t *testing.T) { + fake := filepath.FromSlash("./testdata/fake-executable") + _, err := UpdateCommand(fake, semver.MustParse("1.0.0"), "rhysd/") + if err == nil { + t.Fatal("Unknown repo should cause an error") + } + if !strings.Contains(err.Error(), "Invalid slug format") { + t.Fatal("Unexpected error:", err) + } +} + +func TestInvalidAssetURL(t *testing.T) { + err := UpdateTo("https://github.com/rhysd/non-existing-repo/releases/download/v1.2.3/foo.zip", "foo") + if err == nil { + t.Fatal("Error should occur for URL not found") + } + if !strings.Contains(err.Error(), "Failed to download a release file") { + t.Fatal("Unexpected error:", err) + } +} + +func TestBrokenAsset(t *testing.T) { + asset := "https://github.com/rhysd-test/test-incorrect-release/releases/download/invalid/broken-zip.zip" + err := UpdateTo(asset, "foo") + if err == nil { + t.Fatal("Error should occur for URL not found") + } + if !strings.Contains(err.Error(), "Failed to uncompress zip file") { + 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) + } +} + +func TestUpdateFromGitHubPrivateRepo(t *testing.T) { + token := os.Getenv("GITHUB_PRIVATE_TOKEN") + if token == "" { + t.Skip("because GITHUB_PRIVATE_TOKEN is not set") + } + + setupTestBinary() + defer teardownTestBinary() + + up, err := NewUpdater(Config{APIToken: token}) + 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, "rhysd/private-release-test") + 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) + } +} diff --git a/selfupdate/updater.go b/selfupdate/updater.go new file mode 100644 index 0000000..32cf5e0 --- /dev/null +++ b/selfupdate/updater.go @@ -0,0 +1,99 @@ +package selfupdate + +import ( + "context" + "fmt" + "net/http" + "os" + "regexp" + + "github.com/google/go-github/v30/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 + validator Validator + filters []*regexp.Regexp +} + +// 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 + // Validator represents types which enable additional validation of downloaded release. + Validator Validator + // Filters are regexp used to filter on specific assets for releases with multiple assets. + // An asset is selected if it matches any of those, in addition to the regular tag, os, arch, extensions. + // Please make sure that your filter(s) uniquely match an asset. + Filters []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) + + filtersRe := make([]*regexp.Regexp, 0, len(config.Filters)) + for _, filter := range config.Filters { + re, err := regexp.Compile(filter) + if err != nil { + return nil, fmt.Errorf("Could not compile regular expression %q for filtering releases: %v", filter, err) + } + filtersRe = append(filtersRe, re) + } + + if config.EnterpriseBaseURL == "" { + client := github.NewClient(hc) + return &Updater{api: client, apiCtx: ctx, validator: config.Validator, filters: filtersRe}, nil + } + + u := config.EnterpriseUploadURL + if u == "" { + u = config.EnterpriseBaseURL + } + client, err := github.NewEnterpriseClient(config.EnterpriseBaseURL, u, hc) + if err != nil { + return nil, err + } + return &Updater{api: client, apiCtx: ctx, validator: config.Validator, filters: filtersRe}, 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{api: github.NewClient(client), apiCtx: ctx} +} diff --git a/selfupdate/updater_test.go b/selfupdate/updater_test.go new file mode 100644 index 0000000..e9f9076 --- /dev/null +++ b/selfupdate/updater_test.go @@ -0,0 +1,106 @@ +package selfupdate + +import ( + "os" + "strings" + "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 TestGitHubTokenIsNotSet(t *testing.T) { + token := os.Getenv("GITHUB_TOKEN") + if token != "" { + defer os.Setenv("GITHUB_TOKEN", token) + } + os.Setenv("GITHUB_TOKEN", "") + _ = DefaultUpdater() + if _, err := NewUpdater(Config{}); err != nil { + t.Error("Failed to initialize updater with empty 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 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") + } +} + +func TestCompileRegexForFiltering(t *testing.T) { + filters := []string{ + "^hello$", + "^(\\d\\.)+\\d$", + } + up, err := NewUpdater(Config{ + Filters: filters, + }) + if err != nil { + t.Fatal(err) + } + if len(up.filters) != 2 { + t.Fatalf("Wanted 2 regexes but got %d", len(up.filters)) + } + for i, r := range up.filters { + want := filters[i] + got := r.String() + if want != got { + t.Errorf("Compiled regex is %q but specified was %q", got, want) + } + } +} + +func TestFilterRegexIsBroken(t *testing.T) { + _, err := NewUpdater(Config{ + Filters: []string{"(foo"}, + }) + if err == nil { + t.Fatal("Error unexpectedly did not occur") + } + msg := err.Error() + if !strings.Contains(msg, "Could not compile regular expression \"(foo\" for filtering releases") { + t.Fatalf("Error message is unexpected: %q", msg) + } +} diff --git a/selfupdate/validate.go b/selfupdate/validate.go new file mode 100644 index 0000000..10066aa --- /dev/null +++ b/selfupdate/validate.go @@ -0,0 +1,73 @@ +package selfupdate + +import ( + "crypto/ecdsa" + "crypto/sha256" + "encoding/asn1" + "fmt" + "math/big" +) + +// Validator represents an interface which enables additional validation of releases. +type Validator interface { + // Validate validates release bytes against an additional asset bytes. + // See SHA2Validator or ECDSAValidator for more information. + Validate(release, asset []byte) error + // Suffix describes the additional file ending which is used for finding the + // additional asset. + Suffix() string +} + +// SHA2Validator specifies a SHA256 validator for additional file validation +// before updating. +type SHA2Validator struct { +} + +// Validate validates the SHA256 sum of the release against the contents of an +// additional asset file. +func (v *SHA2Validator) Validate(release, asset []byte) error { + calculatedHash := fmt.Sprintf("%x", sha256.Sum256(release)) + hash := fmt.Sprintf("%s", asset[:sha256.BlockSize]) + if calculatedHash != hash { + return fmt.Errorf("sha2: validation failed: hash mismatch: expected=%q, got=%q", calculatedHash, hash) + } + return nil +} + +// Suffix returns the suffix for SHA2 validation. +func (v *SHA2Validator) Suffix() string { + return ".sha256" +} + +// ECDSAValidator specifies a ECDSA validator for additional file validation +// before updating. +type ECDSAValidator struct { + PublicKey *ecdsa.PublicKey +} + +// Validate validates the ECDSA signature the release against the signature +// contained in an additional asset file. +// additional asset file. +func (v *ECDSAValidator) Validate(input, signature []byte) error { + h := sha256.New() + h.Write(input) + + var rs struct { + R *big.Int + S *big.Int + } + if _, err := asn1.Unmarshal(signature, &rs); err != nil { + return fmt.Errorf("failed to unmarshal ecdsa signature: %v", err) + } + + if !ecdsa.Verify(v.PublicKey, h.Sum([]byte{}), rs.R, rs.S) { + return fmt.Errorf("ecdsa: signature verification failed") + } + + return nil +} + +// Suffix returns the suffix for ECDSA validation. +func (v *ECDSAValidator) Suffix() string { + return ".sig" +} diff --git a/selfupdate/validate_test.go b/selfupdate/validate_test.go new file mode 100644 index 0000000..3c6ed6e --- /dev/null +++ b/selfupdate/validate_test.go @@ -0,0 +1,136 @@ +package selfupdate + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "io/ioutil" + "testing" +) + +func TestSHA2Validator(t *testing.T) { + validator := &SHA2Validator{} + data, err := ioutil.ReadFile("testdata/foo.zip") + if err != nil { + t.Fatal(err) + } + hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256") + if err != nil { + t.Fatal(err) + } + if err := validator.Validate(data, hashData); err != nil { + t.Fatal(err) + } +} + +func TestSHA2ValidatorFail(t *testing.T) { + validator := &SHA2Validator{} + data, err := ioutil.ReadFile("testdata/foo.zip") + if err != nil { + t.Fatal(err) + } + hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256") + if err != nil { + t.Fatal(err) + } + hashData[0] = '0' + if err := validator.Validate(data, hashData); err == nil { + t.Fatal(err) + } +} + +func TestECDSAValidator(t *testing.T) { + pemData, err := ioutil.ReadFile("testdata/Test.crt") + if err != nil { + t.Fatal(err) + } + + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate") + } + + pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Errorf("PublicKey is not ECDSA") + } + + validator := &ECDSAValidator{ + PublicKey: pubKey, + } + data, err := ioutil.ReadFile("testdata/foo.zip") + if err != nil { + t.Fatal(err) + } + signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig") + if err != nil { + t.Fatal(err) + } + if err := validator.Validate(data, signatureData); err != nil { + t.Fatal(err) + } +} + +func TestECDSAValidatorFail(t *testing.T) { + pemData, err := ioutil.ReadFile("testdata/Test.crt") + if err != nil { + t.Fatal(err) + } + + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate") + } + + pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Errorf("PublicKey is not ECDSA") + } + + validator := &ECDSAValidator{ + PublicKey: pubKey, + } + data, err := ioutil.ReadFile("testdata/foo.tar.xz") + if err != nil { + t.Fatal(err) + } + signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig") + if err != nil { + t.Fatal(err) + } + if err := validator.Validate(data, signatureData); err == nil { + t.Fatal(err) + } +} + +func TestValidatorSuffix(t *testing.T) { + for _, test := range []struct { + v Validator + suffix string + }{ + { + v: &SHA2Validator{}, + suffix: ".sha256", + }, + { + v: &ECDSAValidator{}, + suffix: ".sig", + }, + } { + want := test.suffix + got := test.v.Suffix() + if want != got { + t.Errorf("Wanted %q but got %q", want, got) + } + } +}