├── selfupdate ├── testdata │ ├── invalid.gz │ ├── invalid.zip │ ├── invalid-gzip.tar.gz │ ├── invalid.xz │ ├── invalid-xz.tar.xz │ ├── fake-executable │ ├── foo.zip.sha256 │ ├── fake-executable.exe │ ├── empty.zip │ ├── foo.tgz │ ├── foo.zip │ ├── foo.tar.gz │ ├── foo.tar.xz │ ├── foo.zip.sig │ ├── github-release-test │ │ └── main.go │ ├── empty.tar.gz │ ├── single-file.gz │ ├── single-file.xz │ ├── single-file.gzip │ ├── single-file.zip │ ├── bar-not-found.gzip │ ├── bar-not-found.zip │ ├── invalid-tar.tar.gz │ ├── invalid-tar.tar.xz │ ├── bar-not-found.tar.gz │ ├── bar-not-found.tar.xz │ ├── Test.crt │ └── Test.pem ├── e2e_test.go ├── log_test.go ├── log.go ├── release.go ├── doc.go ├── validate.go ├── updater_test.go ├── validate_test.go ├── updater.go ├── uncompress_test.go ├── uncompress.go ├── update.go ├── detect.go ├── update_test.go └── detect_test.go ├── .gitignore ├── Guardfile ├── scripts └── make-release.sh ├── go.mod ├── .travis.yml ├── cmd ├── detect-latest-release │ ├── README.md │ └── main.go ├── go-get-release │ ├── README.md │ └── main.go └── selfupdate-example │ └── main.go ├── .appveyor.yml ├── LICENSE ├── CHANGELOG.md ├── go.sum └── README.md /selfupdate/testdata/invalid.gz: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /selfupdate/testdata/invalid.zip: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /selfupdate/testdata/invalid-gzip.tar.gz: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /selfupdate/testdata/invalid.xz: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /selfupdate/testdata/invalid-xz.tar.xz: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /selfupdate/testdata/fake-executable: -------------------------------------------------------------------------------- 1 | this file is used for passing check of file existence in update tests. 2 | -------------------------------------------------------------------------------- /selfupdate/testdata/foo.zip.sha256: -------------------------------------------------------------------------------- 1 | e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip 2 | -------------------------------------------------------------------------------- /selfupdate/testdata/fake-executable.exe: -------------------------------------------------------------------------------- 1 | this file is used for passing check of file existence in update tests. 2 | -------------------------------------------------------------------------------- /selfupdate/testdata/empty.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/empty.zip -------------------------------------------------------------------------------- /selfupdate/testdata/foo.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/foo.tgz -------------------------------------------------------------------------------- /selfupdate/testdata/foo.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/foo.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /selfupdate-example 2 | /release 3 | /env.sh 4 | /detect-latest-release 5 | /go-get-release 6 | /coverage.out 7 | -------------------------------------------------------------------------------- /selfupdate/testdata/foo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/foo.tar.gz -------------------------------------------------------------------------------- /selfupdate/testdata/foo.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/foo.tar.xz -------------------------------------------------------------------------------- /selfupdate/testdata/foo.zip.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/foo.zip.sig -------------------------------------------------------------------------------- /selfupdate/testdata/github-release-test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | println("not released yet!") 5 | } 6 | -------------------------------------------------------------------------------- /selfupdate/testdata/empty.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/empty.tar.gz -------------------------------------------------------------------------------- /selfupdate/testdata/single-file.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/single-file.gz -------------------------------------------------------------------------------- /selfupdate/testdata/single-file.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/single-file.xz -------------------------------------------------------------------------------- /selfupdate/testdata/single-file.gzip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/single-file.gzip -------------------------------------------------------------------------------- /selfupdate/testdata/single-file.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/single-file.zip -------------------------------------------------------------------------------- /selfupdate/testdata/bar-not-found.gzip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/bar-not-found.gzip -------------------------------------------------------------------------------- /selfupdate/testdata/bar-not-found.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/bar-not-found.zip -------------------------------------------------------------------------------- /selfupdate/testdata/invalid-tar.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/invalid-tar.tar.gz -------------------------------------------------------------------------------- /selfupdate/testdata/invalid-tar.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/invalid-tar.tar.xz -------------------------------------------------------------------------------- /selfupdate/testdata/bar-not-found.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/bar-not-found.tar.gz -------------------------------------------------------------------------------- /selfupdate/testdata/bar-not-found.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hahwul/go-github-selfupdate/master/selfupdate/testdata/bar-not-found.tar.xz -------------------------------------------------------------------------------- /selfupdate/e2e_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "testing" 7 | ) 8 | 9 | func TestRunSelfUpdateExample(t *testing.T) { 10 | if testing.Short() { 11 | t.Skip("skipping test in short mode.") 12 | } 13 | 14 | t.Skip("TODO") 15 | 16 | if err := exec.Command("go", "build", "../cmd/selfupdate-example").Run(); err != nil { 17 | t.Fatal(err) 18 | } 19 | defer os.Remove("selfupdate-example") 20 | 21 | // TODO 22 | } 23 | -------------------------------------------------------------------------------- /selfupdate/testdata/Test.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU 3 | ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD 4 | EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A 5 | QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX 6 | rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh 7 | AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ 8 | 7cugOPws7/OoUo1124LKPugISg== 9 | -----END CERTIFICATE----- 10 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :shell do 2 | watch /^selfupdate\/.+\.go$/ do |m| 3 | puts "#{Time.now}: #{m[0]}" 4 | case m[0] 5 | when /_test\.go$/ 6 | parent = File.dirname m[0] 7 | sources = Dir["#{parent}/*.go"].reject{|p| p.end_with? '_test.go'}.join(' ') 8 | system "go test -v -short #{m[0]} #{sources}" 9 | else 10 | system 'go build ./selfupdate/' 11 | end 12 | end 13 | 14 | watch /^cmd\/selfupdate-example\/.+\.go$/ do |m| 15 | puts "#{Time.now}: #{m[0]}" 16 | system 'go build ./cmd/selfupdate-example/' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /scripts/make-release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -e 4 | 5 | if [ ! -d .git ]; then 6 | echo 'Run this script from root of repository' 1>&2 7 | exit 1 8 | fi 9 | 10 | executable=selfupdate-example 11 | 12 | rm -rf release 13 | gox -verbose ./cmd/$executable 14 | mkdir -p release 15 | mv selfupdate-example_* release/ 16 | cd release 17 | for bin in *; do 18 | if [[ "$bin" == *windows* ]]; then 19 | command="${executable}.exe" 20 | else 21 | command="$executable" 22 | fi 23 | mv "$bin" "$command" 24 | zip "${bin}.zip" "$command" 25 | rm "$command" 26 | done 27 | -------------------------------------------------------------------------------- /selfupdate/log_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestEnableDisableLog(t *testing.T) { 8 | defer DisableLog() 9 | 10 | EnableLog() 11 | if !logEnabled { 12 | t.Fatal("Log should be enabled") 13 | } 14 | EnableLog() 15 | if !logEnabled { 16 | t.Fatal("Log should be enabled") 17 | } 18 | DisableLog() 19 | if logEnabled { 20 | t.Fatal("Log should not be enabled") 21 | } 22 | DisableLog() 23 | if logEnabled { 24 | t.Fatal("Log should not be enabled") 25 | } 26 | EnableLog() 27 | if !logEnabled { 28 | t.Fatal("Log should be enabled") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hahwul/go-github-selfupdate 2 | 3 | require ( 4 | github.com/blang/semver v3.5.1+incompatible 5 | github.com/google/go-github v17.0.0+incompatible 6 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf 7 | github.com/kr/pretty v0.1.0 // indirect 8 | github.com/onsi/gomega v1.4.2 // indirect 9 | github.com/tcnksm/go-gitconfig v0.1.2 10 | github.com/ulikunitz/xz v0.5.5 11 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 12 | google.golang.org/appengine v1.3.0 // indirect 13 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 14 | ) 15 | 16 | go 1.13 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | os: 3 | - linux 4 | - osx 5 | env: 6 | - GO111MODULE=on 7 | install: 8 | - go version 9 | - go env 10 | - go get -t -d -v ./... 11 | script: 12 | - go build ./selfupdate/ 13 | - go build ./cmd/selfupdate-example/ 14 | - go build ./cmd/detect-latest-release/ 15 | - go build ./cmd/go-get-release/ 16 | - | 17 | if [[ "${GITHUB_TOKEN}" != "" ]]; then 18 | go test -v -race -coverprofile=coverage.txt ./selfupdate 19 | else 20 | go test -v -race -short ./selfupdate 21 | fi 22 | after_success: 23 | - if [ -f coverage.txt ]; then bash <(curl -s https://codecov.io/bash); fi 24 | -------------------------------------------------------------------------------- /selfupdate/log.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "io/ioutil" 5 | stdlog "log" 6 | "os" 7 | ) 8 | 9 | var log = stdlog.New(ioutil.Discard, "", 0) 10 | var logEnabled = false 11 | 12 | // EnableLog enables to output logging messages in library 13 | func EnableLog() { 14 | if logEnabled { 15 | return 16 | } 17 | logEnabled = true 18 | log.SetOutput(os.Stderr) 19 | log.SetFlags(stdlog.Ltime) 20 | } 21 | 22 | // DisableLog disables to output logging messages in library 23 | func DisableLog() { 24 | if !logEnabled { 25 | return 26 | } 27 | logEnabled = false 28 | log.SetOutput(ioutil.Discard) 29 | log.SetFlags(0) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/detect-latest-release/README.md: -------------------------------------------------------------------------------- 1 | This command line tool is a small wrapper of [`selfupdate.DetectLatest()`](https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate#DetectLatest). 2 | 3 | Please install using `go get`. 4 | 5 | ``` 6 | $ go get -u github.com/rhysd/go-github-selfupdate/cmd/detect-latest-release 7 | ``` 8 | 9 | To know the usage, please try the command without any argument. 10 | 11 | ``` 12 | $ detect-latest-release 13 | ``` 14 | 15 | For example, following shows the latest version of [github-clone-all](https://github.com/rhysd/github-clone-all). 16 | 17 | ``` 18 | $ detect-latest-release rhysd/github-clone-all 19 | ``` 20 | 21 | -------------------------------------------------------------------------------- /selfupdate/testdata/Test.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIJvTkRedVrQDNjCb9/RfVjzRwz8S059Y1J6w2N8gy8jVoAoGCCqGSM49 3 | AwEHoUQDQgAEs8fjo/Mi5A3c2v2YxV6AQPJnr70qYMEpsmqn0BTcI8RhZUgB46tW 4 | qeDYdO15yQKbZjfI/dr0fvS21jyW0GSXrA== 5 | -----END EC PRIVATE KEY----- 6 | -----BEGIN CERTIFICATE----- 7 | MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU 8 | ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD 9 | EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A 10 | QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX 11 | rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh 12 | AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ 13 | 7cugOPws7/OoUo1124LKPugISg== 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /.appveyor.yml: -------------------------------------------------------------------------------- 1 | version: "{build}" 2 | clone_depth: 1 3 | clone_folder: c:\outside-gopath 4 | environment: 5 | GOPATH: c:\gopath 6 | GO111MODULE: on 7 | install: 8 | - echo %PATH% 9 | - echo %GOPATH% 10 | - go version 11 | - go env 12 | - go get -v -t -d ./... 13 | build: off 14 | test_script: 15 | - go build ./selfupdate 16 | - go build ./cmd/selfupdate-example 17 | - go build ./cmd/detect-latest-release 18 | - go build ./cmd/go-get-release/ 19 | - ps: | 20 | if (Test-Path env:GITHUB_TOKEN) { 21 | go test -v -race "-coverprofile=coverage.txt" ./selfupdate 22 | } else { 23 | go test -v -race -short ./selfupdate 24 | } 25 | after_test: 26 | - "SET PATH=C:\\Python34;C:\\Python34\\Scripts;%PATH%" 27 | - pip install codecov 28 | - codecov -f "coverage.txt" 29 | deploy: off 30 | -------------------------------------------------------------------------------- /selfupdate/release.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/blang/semver" 7 | ) 8 | 9 | // Release represents a release asset for current OS and arch. 10 | type Release struct { 11 | // Version is the version of the release 12 | Version semver.Version 13 | // AssetURL is a URL to the uploaded file for the release 14 | AssetURL string 15 | // AssetSize represents the size of asset in bytes 16 | AssetByteSize int 17 | // AssetID is the ID of the asset on GitHub 18 | AssetID int64 19 | // ValidationAssetID is the ID of additional validaton asset on GitHub 20 | ValidationAssetID int64 21 | // URL is a URL to release page for browsing 22 | URL string 23 | // ReleaseNotes is a release notes of the release 24 | ReleaseNotes string 25 | // Name represents a name of the release 26 | Name string 27 | // PublishedAt is the time when the release was published 28 | PublishedAt *time.Time 29 | // RepoOwner is the owner of the repository of the release 30 | RepoOwner string 31 | // RepoName is the name of the repository of the release 32 | RepoName string 33 | } 34 | -------------------------------------------------------------------------------- /cmd/go-get-release/README.md: -------------------------------------------------------------------------------- 1 | Like `go get`, but it downloads and installs the latest release binary from GitHub instead. 2 | 3 | Please download a binary from [release page](https://github.com/rhysd/go-github-selfupdate/releases/tag/go-get-release) 4 | and put it in `$PATH` or build from source with `go get`. 5 | 6 | ``` 7 | $ go get -u github.com/rhysd/go-github-selfupdate/cmd/go-get-release 8 | ``` 9 | 10 | Usage is quite similar to `go get`. But `{package}` must be hosted on GitHub. So it needs to start with `github.com/`. 11 | 12 | ``` 13 | $ go-get-release {package} 14 | ``` 15 | 16 | Please note that this command assumes that specified package is following Git tag naming rules and 17 | released binaries naming rules described in [README](../../README.md). 18 | 19 | For example, following command downloads and installs the released binary of [ghr](https://github.com/tcnksm/ghr) 20 | to `$GOPATH/bin`. 21 | 22 | ``` 23 | $ go-get-release github.com/tcnksm/ghr 24 | Command was updated to the latest version 0.5.4: /Users/you/.go/bin/ghr 25 | 26 | $ ghr -version 27 | ghr version v0.5.4 (a12ff1c) 28 | ``` 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | the MIT License 2 | 3 | Copyright (c) 2017 rhysd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 17 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 20 | THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [v1.2.1] - 2019-12-19 4 | 5 | - Fix `.tgz` file was not handled as `.tar.gz`. 6 | 7 | 8 | ## [v1.2.0] - 2019-12-19 9 | 10 | - New Feature: Filtering releases by matching regular expressions to release names (Thanks to [@fredbi](https://github.com/fredbi)). 11 | Regular expression strings specified at `Filters` field in `Config` struct are used on detecting the 12 | latest release. Please read [documentation](https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate#Config) 13 | for more details. 14 | - Allow `{cmd}_{os}_{arch}` format for executable names. 15 | - `.tgz` file name suffix was supported. 16 | 17 | 18 | ## [v1.1.0] - 2018-11-10 19 | 20 | - New Feature: Signature validation for release assets (Thanks to [@tobiaskohlbau](https://github.com/tobiaskohlbau)). 21 | Please read [the instruction](https://github.com/rhysd/go-github-selfupdate#hash-or-signature-validation) for usage. 22 | 23 | 24 | ## [v1.0.0] - 2018-09-23 25 | 26 | First release! :tada: 27 | 28 | 29 | [Unreleased]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.1...HEAD 30 | [v1.2.1]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.0...v1.2.1 31 | [v1.2.0]: https://github.com/rhysd/go-github-selfupdate/compare/go-get-release...v1.2.0 32 | [v1.1.0]: https://github.com/rhysd/go-github-selfupdate/compare/v1.0.0...v1.1.0 33 | [v1.0.0]: https://github.com/rhysd/go-github-selfupdate/compare/example-1.2.4...v1.0.0 34 | -------------------------------------------------------------------------------- /cmd/selfupdate-example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/blang/semver" 7 | "github.com/rhysd/go-github-selfupdate/selfupdate" 8 | "os" 9 | ) 10 | 11 | const version = "1.2.3" 12 | 13 | func selfUpdate(slug string) error { 14 | selfupdate.EnableLog() 15 | 16 | previous := semver.MustParse(version) 17 | latest, err := selfupdate.UpdateSelf(previous, slug) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if previous.Equals(latest.Version) { 23 | fmt.Println("Current binary is the latest version", version) 24 | } else { 25 | fmt.Println("Update successfully done to version", latest.Version) 26 | fmt.Println("Release note:\n", latest.ReleaseNotes) 27 | } 28 | return nil 29 | } 30 | 31 | func usage() { 32 | fmt.Fprintln(os.Stderr, "Usage: selfupdate-example [flags]\n") 33 | flag.PrintDefaults() 34 | } 35 | 36 | func main() { 37 | help := flag.Bool("help", false, "Show this help") 38 | ver := flag.Bool("version", false, "Show version") 39 | update := flag.Bool("selfupdate", false, "Try go-github-selfupdate via GitHub") 40 | slug := flag.String("slug", "rhysd/go-github-selfupdate", "Repository of this command") 41 | 42 | flag.Usage = usage 43 | flag.Parse() 44 | 45 | if *help { 46 | usage() 47 | os.Exit(0) 48 | } 49 | 50 | if *ver { 51 | fmt.Println(version) 52 | os.Exit(0) 53 | } 54 | 55 | if *update { 56 | if err := selfUpdate(*slug); err != nil { 57 | fmt.Fprintln(os.Stderr, err) 58 | os.Exit(1) 59 | } 60 | os.Exit(0) 61 | } 62 | 63 | usage() 64 | } 65 | -------------------------------------------------------------------------------- /cmd/detect-latest-release/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/rhysd/go-github-selfupdate/selfupdate" 7 | "os" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | func usage() { 13 | 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") 14 | flag.PrintDefaults() 15 | } 16 | 17 | func main() { 18 | asset := flag.Bool("asset", false, "Output URL to asset") 19 | notes := flag.Bool("release-notes", false, "Output release notes additionally") 20 | url := flag.Bool("url", false, "Output URL for release page") 21 | 22 | flag.Usage = usage 23 | flag.Parse() 24 | 25 | if flag.NArg() != 1 { 26 | usage() 27 | os.Exit(1) 28 | } 29 | 30 | repo := flag.Arg(0) 31 | if strings.HasPrefix(repo, "https://") { 32 | repo = repo[len("https://"):] 33 | } 34 | if strings.HasPrefix(repo, "github.com/") { 35 | repo = repo[len("github.com/"):] 36 | } 37 | 38 | matched, err := regexp.MatchString("[^/]+/[^/]+", repo) 39 | if err != nil { 40 | panic(err) 41 | } 42 | if !matched { 43 | usage() 44 | os.Exit(1) 45 | } 46 | 47 | latest, found, err := selfupdate.DetectLatest(repo) 48 | if err != nil { 49 | fmt.Fprintln(os.Stderr, err) 50 | os.Exit(1) 51 | } 52 | if !found { 53 | fmt.Println("No release was found") 54 | } else { 55 | if *asset { 56 | fmt.Println(latest.AssetURL) 57 | } else if *url { 58 | fmt.Println(latest.URL) 59 | } else { 60 | fmt.Println(latest.Version) 61 | if *notes { 62 | fmt.Printf("\nRelease Notes:\n%s\n", latest.ReleaseNotes) 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /selfupdate/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package selfupdate provides self-update mechanism to Go command line tools. 3 | 4 | Go does not provide the way to install/update the stable version of tools. By default, Go command line tools are updated 5 | 6 | - using `go get -u` (updating to HEAD) 7 | - using system's package manager (depending on the platform) 8 | - downloading executables from GitHub release page manually 9 | 10 | By using this library, you will get 4th choice: 11 | 12 | - from your command line tool directly (and automatically) 13 | 14 | go-github-selfupdate detects the information of the latest release via GitHub Releases API and check the current version. 15 | If newer version than itself is detected, it downloads released binary from GitHub and replaces itself. 16 | 17 | - Automatically detects the latest version of released binary on GitHub 18 | - Retrieve the proper binary for the OS and arch where the binary is running 19 | - Update the binary with rollback support on failure 20 | - Tested on Linux, macOS and Windows 21 | - Many archive and compression formats are supported (zip, gzip, xzip, tar) 22 | 23 | There are some naming rules. Please read following links. 24 | 25 | Naming Rules of Released Binaries: 26 | https://github.com/rhysd/go-github-selfupdate#naming-rules-of-released-binaries 27 | 28 | Naming Rules of Git Tags: 29 | https://github.com/rhysd/go-github-selfupdate#naming-rules-of-git-tags 30 | 31 | This package is hosted on GitHub: 32 | https://github.com/rhysd/go-github-selfupdate 33 | 34 | Small CLI tools as wrapper of this library are available also: 35 | https://github.com/rhysd/go-github-selfupdate/cmd/detect-latest-release 36 | https://github.com/rhysd/go-github-selfupdate/cmd/go-get-release 37 | */ 38 | package selfupdate 39 | -------------------------------------------------------------------------------- /selfupdate/validate.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/sha256" 6 | "encoding/asn1" 7 | "fmt" 8 | "math/big" 9 | ) 10 | 11 | // Validator represents an interface which enables additional validation of releases. 12 | type Validator interface { 13 | // Validate validates release bytes against an additional asset bytes. 14 | // See SHA2Validator or ECDSAValidator for more information. 15 | Validate(release, asset []byte) error 16 | // Suffix describes the additional file ending which is used for finding the 17 | // additional asset. 18 | Suffix() string 19 | } 20 | 21 | // SHA2Validator specifies a SHA256 validator for additional file validation 22 | // before updating. 23 | type SHA2Validator struct { 24 | } 25 | 26 | // Validate validates the SHA256 sum of the release against the contents of an 27 | // additional asset file. 28 | func (v *SHA2Validator) Validate(release, asset []byte) error { 29 | calculatedHash := fmt.Sprintf("%x", sha256.Sum256(release)) 30 | hash := fmt.Sprintf("%s", asset[:sha256.BlockSize]) 31 | if calculatedHash != hash { 32 | return fmt.Errorf("sha2: validation failed: hash mismatch: expected=%q, got=%q", calculatedHash, hash) 33 | } 34 | return nil 35 | } 36 | 37 | // Suffix returns the suffix for SHA2 validation. 38 | func (v *SHA2Validator) Suffix() string { 39 | return ".sha256" 40 | } 41 | 42 | // ECDSAValidator specifies a ECDSA validator for additional file validation 43 | // before updating. 44 | type ECDSAValidator struct { 45 | PublicKey *ecdsa.PublicKey 46 | } 47 | 48 | // Validate validates the ECDSA signature the release against the signature 49 | // contained in an additional asset file. 50 | // additional asset file. 51 | func (v *ECDSAValidator) Validate(input, signature []byte) error { 52 | h := sha256.New() 53 | h.Write(input) 54 | 55 | var rs struct { 56 | R *big.Int 57 | S *big.Int 58 | } 59 | if _, err := asn1.Unmarshal(signature, &rs); err != nil { 60 | return fmt.Errorf("failed to unmarshal ecdsa signature: %v", err) 61 | } 62 | 63 | if !ecdsa.Verify(v.PublicKey, h.Sum([]byte{}), rs.R, rs.S) { 64 | return fmt.Errorf("ecdsa: signature verification failed") 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // Suffix returns the suffix for ECDSA validation. 71 | func (v *ECDSAValidator) Suffix() string { 72 | return ".sig" 73 | } 74 | -------------------------------------------------------------------------------- /selfupdate/updater_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestGitHubTokenEnv(t *testing.T) { 10 | token := os.Getenv("GITHUB_TOKEN") 11 | if token == "" { 12 | t.Skip("because $GITHUB_TOKEN is not set") 13 | } 14 | _ = DefaultUpdater() 15 | if _, err := NewUpdater(Config{}); err != nil { 16 | t.Error("Failed to initialize updater with empty config") 17 | } 18 | if _, err := NewUpdater(Config{APIToken: token}); err != nil { 19 | t.Error("Failed to initialize updater with API token config") 20 | } 21 | } 22 | 23 | func TestGitHubTokenIsNotSet(t *testing.T) { 24 | token := os.Getenv("GITHUB_TOKEN") 25 | if token != "" { 26 | defer os.Setenv("GITHUB_TOKEN", token) 27 | } 28 | os.Setenv("GITHUB_TOKEN", "") 29 | _ = DefaultUpdater() 30 | if _, err := NewUpdater(Config{}); err != nil { 31 | t.Error("Failed to initialize updater with empty config") 32 | } 33 | } 34 | 35 | func TestGitHubEnterpriseClient(t *testing.T) { 36 | url := "https://github.company.com/api/v3/" 37 | up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: url}) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | if up.api.BaseURL.String() != url { 42 | t.Error("Base URL was set to", up.api.BaseURL, ", want", url) 43 | } 44 | if up.api.UploadURL.String() != url { 45 | t.Error("Upload URL was set to", up.api.UploadURL, ", want", url) 46 | } 47 | 48 | url2 := "https://upload.github.company.com/api/v3/" 49 | up, err = NewUpdater(Config{ 50 | APIToken: "hogehoge", 51 | EnterpriseBaseURL: url, 52 | EnterpriseUploadURL: url2, 53 | }) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | if up.api.BaseURL.String() != url { 58 | t.Error("Base URL was set to", up.api.BaseURL, ", want", url) 59 | } 60 | if up.api.UploadURL.String() != url2 { 61 | t.Error("Upload URL was set to", up.api.UploadURL, ", want", url2) 62 | } 63 | } 64 | 65 | func TestGitHubEnterpriseClientInvalidURL(t *testing.T) { 66 | _, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: ":this is not a URL"}) 67 | if err == nil { 68 | t.Fatal("Invalid URL should raise an error") 69 | } 70 | } 71 | 72 | func TestCompileRegexForFiltering(t *testing.T) { 73 | filters := []string{ 74 | "^hello$", 75 | "^(\\d\\.)+\\d$", 76 | } 77 | up, err := NewUpdater(Config{ 78 | Filters: filters, 79 | }) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | if len(up.filters) != 2 { 84 | t.Fatalf("Wanted 2 regexes but got %d", len(up.filters)) 85 | } 86 | for i, r := range up.filters { 87 | want := filters[i] 88 | got := r.String() 89 | if want != got { 90 | t.Errorf("Compiled regex is %q but specified was %q", got, want) 91 | } 92 | } 93 | } 94 | 95 | func TestFilterRegexIsBroken(t *testing.T) { 96 | _, err := NewUpdater(Config{ 97 | Filters: []string{"(foo"}, 98 | }) 99 | if err == nil { 100 | t.Fatal("Error unexpectedly did not occur") 101 | } 102 | msg := err.Error() 103 | if !strings.Contains(msg, "Could not compile regular expression \"(foo\" for filtering releases") { 104 | t.Fatalf("Error message is unexpected: %q", msg) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /selfupdate/validate_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "io/ioutil" 8 | "testing" 9 | ) 10 | 11 | func TestSHA2Validator(t *testing.T) { 12 | validator := &SHA2Validator{} 13 | data, err := ioutil.ReadFile("testdata/foo.zip") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256") 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | if err := validator.Validate(data, hashData); err != nil { 22 | t.Fatal(err) 23 | } 24 | } 25 | 26 | func TestSHA2ValidatorFail(t *testing.T) { 27 | validator := &SHA2Validator{} 28 | data, err := ioutil.ReadFile("testdata/foo.zip") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256") 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | hashData[0] = '0' 37 | if err := validator.Validate(data, hashData); err == nil { 38 | t.Fatal(err) 39 | } 40 | } 41 | 42 | func TestECDSAValidator(t *testing.T) { 43 | pemData, err := ioutil.ReadFile("testdata/Test.crt") 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | block, _ := pem.Decode(pemData) 49 | if block == nil || block.Type != "CERTIFICATE" { 50 | t.Fatalf("failed to decode PEM block") 51 | } 52 | 53 | cert, err := x509.ParseCertificate(block.Bytes) 54 | if err != nil { 55 | t.Fatalf("failed to parse certificate") 56 | } 57 | 58 | pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) 59 | if !ok { 60 | t.Errorf("PublicKey is not ECDSA") 61 | } 62 | 63 | validator := &ECDSAValidator{ 64 | PublicKey: pubKey, 65 | } 66 | data, err := ioutil.ReadFile("testdata/foo.zip") 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig") 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | if err := validator.Validate(data, signatureData); err != nil { 75 | t.Fatal(err) 76 | } 77 | } 78 | 79 | func TestECDSAValidatorFail(t *testing.T) { 80 | pemData, err := ioutil.ReadFile("testdata/Test.crt") 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | 85 | block, _ := pem.Decode(pemData) 86 | if block == nil || block.Type != "CERTIFICATE" { 87 | t.Fatalf("failed to decode PEM block") 88 | } 89 | 90 | cert, err := x509.ParseCertificate(block.Bytes) 91 | if err != nil { 92 | t.Fatalf("failed to parse certificate") 93 | } 94 | 95 | pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) 96 | if !ok { 97 | t.Errorf("PublicKey is not ECDSA") 98 | } 99 | 100 | validator := &ECDSAValidator{ 101 | PublicKey: pubKey, 102 | } 103 | data, err := ioutil.ReadFile("testdata/foo.tar.xz") 104 | if err != nil { 105 | t.Fatal(err) 106 | } 107 | signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig") 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | if err := validator.Validate(data, signatureData); err == nil { 112 | t.Fatal(err) 113 | } 114 | } 115 | 116 | func TestValidatorSuffix(t *testing.T) { 117 | for _, test := range []struct { 118 | v Validator 119 | suffix string 120 | }{ 121 | { 122 | v: &SHA2Validator{}, 123 | suffix: ".sha256", 124 | }, 125 | { 126 | v: &ECDSAValidator{}, 127 | suffix: ".sig", 128 | }, 129 | } { 130 | want := test.suffix 131 | got := test.v.Suffix() 132 | if want != got { 133 | t.Errorf("Wanted %q but got %q", want, got) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cmd/go-get-release/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/rhysd/go-github-selfupdate/selfupdate" 7 | "go/build" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | var version = "1.0.0" 16 | 17 | func usage() { 18 | fmt.Fprintln(os.Stderr, `Usage: go-get-release [flags] {package} 19 | 20 | go-get-release is like "go get", but it downloads the latest release from 21 | GitHub. {package} must start with "github.com/". 22 | 23 | Flags:`) 24 | flag.PrintDefaults() 25 | } 26 | 27 | func getCommand(pkg string) string { 28 | _, cmd := filepath.Split(pkg) 29 | if cmd == "" { 30 | // When pkg path is ending with path separator, we need to split it out. 31 | // i.e. github.com/rhysd/foo/cmd/bar/ 32 | _, cmd = filepath.Split(cmd) 33 | } 34 | return cmd 35 | } 36 | 37 | func parseSlug(pkg string) (string, bool) { 38 | pkg = pkg[len("github.com/"):] 39 | first := false 40 | for i, r := range pkg { 41 | if r == '/' { 42 | if !first { 43 | first = true 44 | } else { 45 | return pkg[:i], true 46 | } 47 | } 48 | } 49 | if first { 50 | // When 'github.com/foo/bar' is specified, reaching here. 51 | return pkg, true 52 | } 53 | return "", false 54 | } 55 | 56 | func installFrom(url, cmd, path string) error { 57 | res, err := http.Get(url) 58 | if err != nil { 59 | return fmt.Errorf("Failed to download release binary from %s: %s", url, err) 60 | } 61 | defer res.Body.Close() 62 | if res.StatusCode != 200 { 63 | return fmt.Errorf("Failed to download release binary from %s: Invalid response ", url) 64 | } 65 | executable, err := selfupdate.UncompressCommand(res.Body, url, cmd) 66 | if err != nil { 67 | return fmt.Errorf("Failed to uncompress downloaded asset from %s: %s", url, err) 68 | } 69 | bin, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0755) 70 | if err != nil { 71 | return err 72 | } 73 | if _, err := io.Copy(bin, executable); err != nil { 74 | return fmt.Errorf("Failed to write binary to %s: %s", path, err) 75 | } 76 | return nil 77 | } 78 | 79 | func main() { 80 | help := flag.Bool("help", false, "Show help") 81 | ver := flag.Bool("version", false, "Show version") 82 | 83 | flag.Usage = usage 84 | flag.Parse() 85 | 86 | if *ver { 87 | fmt.Println(version) 88 | os.Exit(0) 89 | } 90 | 91 | if *help || flag.NArg() != 1 || !strings.HasPrefix(flag.Arg(0), "github.com/") { 92 | usage() 93 | os.Exit(1) 94 | } 95 | 96 | slug, ok := parseSlug(flag.Arg(0)) 97 | if !ok { 98 | usage() 99 | os.Exit(1) 100 | } 101 | 102 | latest, found, err := selfupdate.DetectLatest(slug) 103 | if err != nil { 104 | fmt.Fprintln(os.Stderr, "Error while detecting the latest version:", err) 105 | os.Exit(1) 106 | } 107 | if !found { 108 | fmt.Fprintln(os.Stderr, "No release was found in", slug) 109 | os.Exit(1) 110 | } 111 | 112 | cmd := getCommand(flag.Arg(0)) 113 | cmdPath := filepath.Join(build.Default.GOPATH, "bin", cmd) 114 | if _, err := os.Stat(cmdPath); err != nil { 115 | // When executable is not existing yet 116 | if err := installFrom(latest.AssetURL, cmd, cmdPath); err != nil { 117 | fmt.Fprintf(os.Stderr, "Error while installing the release binary from %s: %s\n", latest.AssetURL, err) 118 | os.Exit(1) 119 | } 120 | } else { 121 | if err := selfupdate.UpdateTo(latest.AssetURL, cmdPath); err != nil { 122 | fmt.Fprintf(os.Stderr, "Error while replacing the binary with %s: %s\n", latest.AssetURL, err) 123 | os.Exit(1) 124 | } 125 | } 126 | 127 | fmt.Printf(`Command was updated to the latest version %s: %s 128 | 129 | Release Notes: 130 | %s 131 | `, latest.Version, cmdPath, latest.ReleaseNotes) 132 | } 133 | -------------------------------------------------------------------------------- /selfupdate/updater.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "regexp" 9 | 10 | "github.com/google/go-github/github" 11 | gitconfig "github.com/tcnksm/go-gitconfig" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | // Updater is responsible for managing the context of self-update. 16 | // It contains GitHub client and its context. 17 | type Updater struct { 18 | api *github.Client 19 | apiCtx context.Context 20 | validator Validator 21 | filters []*regexp.Regexp 22 | } 23 | 24 | // Config represents the configuration of self-update. 25 | type Config struct { 26 | // APIToken represents GitHub API token. If it's not empty, it will be used for authentication of GitHub API 27 | APIToken string 28 | // EnterpriseBaseURL is a base URL of GitHub API. If you want to use this library with GitHub Enterprise, 29 | // please set "https://{your-organization-address}/api/v3/" to this field. 30 | EnterpriseBaseURL string 31 | // EnterpriseUploadURL is a URL to upload stuffs to GitHub Enterprise instance. This is often the same as an API base URL. 32 | // So if this field is not set and EnterpriseBaseURL is set, EnterpriseBaseURL is also set to this field. 33 | EnterpriseUploadURL string 34 | // Validator represents types which enable additional validation of downloaded release. 35 | Validator Validator 36 | // Filters are regexp used to filter on specific assets for releases with multiple assets. 37 | // An asset is selected if it matches any of those, in addition to the regular tag, os, arch, extensions. 38 | // Please make sure that your filter(s) uniquely match an asset. 39 | Filters []string 40 | } 41 | 42 | func newHTTPClient(ctx context.Context, token string) *http.Client { 43 | if token == "" { 44 | return http.DefaultClient 45 | } 46 | src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 47 | return oauth2.NewClient(ctx, src) 48 | } 49 | 50 | // NewUpdater creates a new updater instance. It initializes GitHub API client. 51 | // If you set your API token to $GITHUB_TOKEN, the client will use it. 52 | func NewUpdater(config Config) (*Updater, error) { 53 | token := config.APIToken 54 | if token == "" { 55 | token = os.Getenv("GITHUB_TOKEN") 56 | } 57 | if token == "" { 58 | token, _ = gitconfig.GithubToken() 59 | } 60 | ctx := context.Background() 61 | hc := newHTTPClient(ctx, token) 62 | 63 | filtersRe := make([]*regexp.Regexp, 0, len(config.Filters)) 64 | for _, filter := range config.Filters { 65 | re, err := regexp.Compile(filter) 66 | if err != nil { 67 | return nil, fmt.Errorf("Could not compile regular expression %q for filtering releases: %v", filter, err) 68 | } 69 | filtersRe = append(filtersRe, re) 70 | } 71 | 72 | if config.EnterpriseBaseURL == "" { 73 | client := github.NewClient(hc) 74 | return &Updater{api: client, apiCtx: ctx, validator: config.Validator, filters: filtersRe}, nil 75 | } 76 | 77 | u := config.EnterpriseUploadURL 78 | if u == "" { 79 | u = config.EnterpriseBaseURL 80 | } 81 | client, err := github.NewEnterpriseClient(config.EnterpriseBaseURL, u, hc) 82 | if err != nil { 83 | return nil, err 84 | } 85 | return &Updater{api: client, apiCtx: ctx, validator: config.Validator, filters: filtersRe}, nil 86 | } 87 | 88 | // DefaultUpdater creates a new updater instance with default configuration. 89 | // It initializes GitHub API client with default API base URL. 90 | // If you set your API token to $GITHUB_TOKEN, the client will use it. 91 | func DefaultUpdater() *Updater { 92 | token := os.Getenv("GITHUB_TOKEN") 93 | if token == "" { 94 | token, _ = gitconfig.GithubToken() 95 | } 96 | ctx := context.Background() 97 | client := newHTTPClient(ctx, token) 98 | return &Updater{api: github.NewClient(client), apiCtx: ctx} 99 | } 100 | -------------------------------------------------------------------------------- /selfupdate/uncompress_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestCompressionNotRequired(t *testing.T) { 13 | buf := []byte{'a', 'b', 'c'} 14 | want := bytes.NewReader(buf) 15 | r, err := UncompressCommand(want, "https://github.com/foo/bar/releases/download/v1.2.3/foo", "foo") 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | have, err := ioutil.ReadAll(r) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | for i, b := range have { 24 | if buf[i] != b { 25 | t.Error(i, "th elem is not the same as wanted. want", buf[i], "but got", b) 26 | } 27 | } 28 | } 29 | 30 | func getArchiveFileExt(file string) string { 31 | if strings.HasSuffix(file, ".tar.gz") { 32 | return ".tar.gz" 33 | } 34 | if strings.HasSuffix(file, ".tar.xz") { 35 | return ".tar.xz" 36 | } 37 | return filepath.Ext(file) 38 | } 39 | 40 | func TestUncompress(t *testing.T) { 41 | for _, n := range []string{ 42 | "testdata/foo.zip", 43 | "testdata/single-file.zip", 44 | "testdata/single-file.gz", 45 | "testdata/single-file.gzip", 46 | "testdata/foo.tar.gz", 47 | "testdata/foo.tgz", 48 | "testdata/foo.tar.xz", 49 | "testdata/single-file.xz", 50 | } { 51 | t.Run(n, func(t *testing.T) { 52 | f, err := os.Open(n) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | ext := getArchiveFileExt(n) 58 | url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext 59 | r, err := UncompressCommand(f, url, "bar") 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | bytes, err := ioutil.ReadAll(r) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | s := string(bytes) 69 | if s != "this is test\n" { 70 | t.Fatal("Uncompressing zip failed into unexpected content", s) 71 | } 72 | }) 73 | } 74 | } 75 | 76 | func TestUncompressInvalidArchive(t *testing.T) { 77 | for _, a := range []struct { 78 | name string 79 | msg string 80 | }{ 81 | {"testdata/invalid.zip", "not a valid zip file"}, 82 | {"testdata/invalid.gz", "Failed to uncompress gzip file"}, 83 | {"testdata/invalid-tar.tar.gz", "Failed to unarchive .tar file"}, 84 | {"testdata/invalid-gzip.tar.gz", "Failed to uncompress .tar.gz file"}, 85 | {"testdata/invalid.xz", "Failed to uncompress xzip file"}, 86 | {"testdata/invalid-tar.tar.xz", "Failed to unarchive .tar file"}, 87 | {"testdata/invalid-xz.tar.xz", "Failed to uncompress .tar.xz file"}, 88 | } { 89 | f, err := os.Open(a.name) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | ext := getArchiveFileExt(a.name) 95 | url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext 96 | _, err = UncompressCommand(f, url, "bar") 97 | if err == nil { 98 | t.Fatal("Error should be raised") 99 | } 100 | if !strings.Contains(err.Error(), a.msg) { 101 | t.Fatal("Unexpected error:", err) 102 | } 103 | } 104 | } 105 | 106 | func TestTargetNotFound(t *testing.T) { 107 | for _, tc := range []struct { 108 | name string 109 | msg string 110 | }{ 111 | {"testdata/empty.zip", "command is not found"}, 112 | {"testdata/bar-not-found.zip", "command is not found"}, 113 | {"testdata/bar-not-found.gzip", "does not match to command"}, 114 | {"testdata/empty.tar.gz", "command is not found"}, 115 | {"testdata/bar-not-found.tar.gz", "command is not found"}, 116 | } { 117 | t.Run(tc.name, func(t *testing.T) { 118 | f, err := os.Open(tc.name) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | ext := getArchiveFileExt(tc.name) 123 | url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext 124 | _, err = UncompressCommand(f, url, "bar") 125 | if err == nil { 126 | t.Fatal("Error should be raised for") 127 | } 128 | if !strings.Contains(err.Error(), tc.msg) { 129 | t.Fatal("Unexpected error:", err) 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /selfupdate/uncompress.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "bytes" 7 | "compress/gzip" 8 | "fmt" 9 | "github.com/ulikunitz/xz" 10 | "io" 11 | "io/ioutil" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | ) 16 | 17 | func matchExecutableName(cmd, target string) bool { 18 | if cmd == target { 19 | return true 20 | } 21 | 22 | o, a := runtime.GOOS, runtime.GOARCH 23 | 24 | // When the contained executable name is full name (e.g. foo_darwin_amd64), 25 | // it is also regarded as a target executable file. (#19) 26 | for _, d := range []rune{'_', '-'} { 27 | c := fmt.Sprintf("%s%c%s%c%s", cmd, d, o, d, a) 28 | if o == "windows" { 29 | c += ".exe" 30 | } 31 | if c == target { 32 | return true 33 | } 34 | } 35 | 36 | return false 37 | } 38 | 39 | func unarchiveTar(src io.Reader, url, cmd string) (io.Reader, error) { 40 | t := tar.NewReader(src) 41 | for { 42 | h, err := t.Next() 43 | if err == io.EOF { 44 | break 45 | } 46 | if err != nil { 47 | return nil, fmt.Errorf("Failed to unarchive .tar file: %s", err) 48 | } 49 | _, name := filepath.Split(h.Name) 50 | if matchExecutableName(cmd, name) { 51 | log.Println("Executable file", h.Name, "was found in tar archive") 52 | return t, nil 53 | } 54 | } 55 | 56 | return nil, fmt.Errorf("File '%s' for the command is not found in %s", cmd, url) 57 | } 58 | 59 | // UncompressCommand uncompresses the given source. Archive and compression format is 60 | // automatically detected from 'url' parameter, which represents the URL of asset. 61 | // This returns a reader for the uncompressed command given by 'cmd'. '.zip', 62 | // '.tar.gz', '.tar.xz', '.tgz', '.gz' and '.xz' are supported. 63 | func UncompressCommand(src io.Reader, url, cmd string) (io.Reader, error) { 64 | if strings.HasSuffix(url, ".zip") { 65 | log.Println("Uncompressing zip file", url) 66 | 67 | // Zip format requires its file size for uncompressing. 68 | // So we need to read the HTTP response into a buffer at first. 69 | buf, err := ioutil.ReadAll(src) 70 | if err != nil { 71 | return nil, fmt.Errorf("Failed to create buffer for zip file: %s", err) 72 | } 73 | 74 | r := bytes.NewReader(buf) 75 | z, err := zip.NewReader(r, r.Size()) 76 | if err != nil { 77 | return nil, fmt.Errorf("Failed to uncompress zip file: %s", err) 78 | } 79 | 80 | for _, file := range z.File { 81 | _, name := filepath.Split(file.Name) 82 | if !file.FileInfo().IsDir() && matchExecutableName(cmd, name) { 83 | log.Println("Executable file", file.Name, "was found in zip archive") 84 | return file.Open() 85 | } 86 | } 87 | 88 | return nil, fmt.Errorf("File '%s' for the command is not found in %s", cmd, url) 89 | } else if strings.HasSuffix(url, ".tar.gz") || strings.HasSuffix(url, ".tgz") { 90 | log.Println("Uncompressing tar.gz file", url) 91 | 92 | gz, err := gzip.NewReader(src) 93 | if err != nil { 94 | return nil, fmt.Errorf("Failed to uncompress .tar.gz file: %s", err) 95 | } 96 | 97 | return unarchiveTar(gz, url, cmd) 98 | } else if strings.HasSuffix(url, ".gzip") || strings.HasSuffix(url, ".gz") { 99 | log.Println("Uncompressing gzip file", url) 100 | 101 | r, err := gzip.NewReader(src) 102 | if err != nil { 103 | return nil, fmt.Errorf("Failed to uncompress gzip file downloaded from %s: %s", url, err) 104 | } 105 | 106 | name := r.Header.Name 107 | if !matchExecutableName(cmd, name) { 108 | return nil, fmt.Errorf("File name '%s' does not match to command '%s' found in %s", name, cmd, url) 109 | } 110 | 111 | log.Println("Executable file", name, "was found in gzip file") 112 | return r, nil 113 | } else if strings.HasSuffix(url, ".tar.xz") { 114 | log.Println("Uncompressing tar.xz file", url) 115 | 116 | xzip, err := xz.NewReader(src) 117 | if err != nil { 118 | return nil, fmt.Errorf("Failed to uncompress .tar.xz file: %s", err) 119 | } 120 | 121 | return unarchiveTar(xzip, url, cmd) 122 | } else if strings.HasSuffix(url, ".xz") { 123 | log.Println("Uncompressing xzip file", url) 124 | 125 | xzip, err := xz.NewReader(src) 126 | if err != nil { 127 | return nil, fmt.Errorf("Failed to uncompress xzip file downloaded from %s: %s", url, err) 128 | } 129 | 130 | log.Println("Uncompressed file from xzip is assumed to be an executable", cmd) 131 | return xzip, nil 132 | } 133 | 134 | log.Println("Uncompression is not needed", url) 135 | return src, nil 136 | } 137 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 2 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 3 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 4 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 5 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 6 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 7 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 8 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 9 | github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= 10 | github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= 11 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 12 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 13 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 14 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 15 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= 16 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= 17 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 18 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 19 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 20 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= 23 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 24 | github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= 25 | github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 26 | github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= 27 | github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= 28 | github.com/ulikunitz/xz v0.5.5 h1:pFrO0lVpTBXLpYw+pnLj6TbvHuyjXMfjGeCwSqCVwok= 29 | github.com/ulikunitz/xz v0.5.5/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 31 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 32 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 33 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 34 | golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= 35 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 36 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 37 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= 38 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 39 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 42 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 43 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 45 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 46 | google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= 47 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 49 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 50 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 51 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 52 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 53 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 54 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 55 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 56 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 57 | -------------------------------------------------------------------------------- /selfupdate/update.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/blang/semver" 15 | "github.com/inconshreveable/go-update" 16 | ) 17 | 18 | func uncompressAndUpdate(src io.Reader, assetURL, cmdPath string) error { 19 | _, cmd := filepath.Split(cmdPath) 20 | asset, err := UncompressCommand(src, assetURL, cmd) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | log.Println("Will update", cmdPath, "to the latest downloaded from", assetURL) 26 | return update.Apply(asset, update.Options{ 27 | TargetPath: cmdPath, 28 | }) 29 | } 30 | 31 | func (up *Updater) downloadDirectlyFromURL(assetURL string) (io.ReadCloser, error) { 32 | req, err := http.NewRequest("GET", assetURL, nil) 33 | if err != nil { 34 | return nil, fmt.Errorf("Failed to create HTTP request to %s: %s", assetURL, err) 35 | } 36 | 37 | req.Header.Add("Accept", "application/octet-stream") 38 | req = req.WithContext(up.apiCtx) 39 | 40 | // OAuth HTTP client is not available to download blob from URL when the URL is a redirect URL 41 | // returned from GitHub Releases API (response status 400). 42 | // Use default HTTP client instead. 43 | res, err := http.DefaultClient.Do(req) 44 | if err != nil { 45 | return nil, fmt.Errorf("Failed to download a release file from %s: %s", assetURL, err) 46 | } 47 | 48 | if res.StatusCode != 200 { 49 | return nil, fmt.Errorf("Failed to download a release file from %s: Not successful status %d", assetURL, res.StatusCode) 50 | } 51 | 52 | return res.Body, nil 53 | } 54 | 55 | // UpdateTo downloads an executable from GitHub Releases API and replace current binary with the downloaded one. 56 | // It downloads a release asset via GitHub Releases API so this function is available for update releases on private repository. 57 | // If a redirect occurs, it fallbacks into directly downloading from the redirect URL. 58 | func (up *Updater) UpdateTo(rel *Release, cmdPath string) error { 59 | var client http.Client 60 | src, redirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.AssetID, &client) 61 | if err != nil { 62 | 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) 63 | } 64 | if redirectURL != "" { 65 | 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) 66 | src, err = up.downloadDirectlyFromURL(redirectURL) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | defer src.Close() 72 | 73 | data, err := ioutil.ReadAll(src) 74 | if err != nil { 75 | return fmt.Errorf("Failed reading asset body: %v", err) 76 | } 77 | 78 | if up.validator == nil { 79 | return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath) 80 | } 81 | 82 | validationSrc, validationRedirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.ValidationAssetID, &client) 83 | if err != nil { 84 | 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) 85 | } 86 | if validationRedirectURL != "" { 87 | 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) 88 | validationSrc, err = up.downloadDirectlyFromURL(validationRedirectURL) 89 | if err != nil { 90 | return err 91 | } 92 | } 93 | 94 | defer validationSrc.Close() 95 | 96 | validationData, err := ioutil.ReadAll(validationSrc) 97 | if err != nil { 98 | return fmt.Errorf("Failed reading validation asset body: %v", err) 99 | } 100 | 101 | if err := up.validator.Validate(data, validationData); err != nil { 102 | return fmt.Errorf("Failed validating asset content: %v", err) 103 | } 104 | 105 | return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath) 106 | } 107 | 108 | // UpdateCommand updates a given command binary to the latest version. 109 | // 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. 110 | func (up *Updater) UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { 111 | if runtime.GOOS == "windows" && !strings.HasSuffix(cmdPath, ".exe") { 112 | // Ensure to add '.exe' to given path on Windows 113 | cmdPath = cmdPath + ".exe" 114 | } 115 | 116 | stat, err := os.Lstat(cmdPath) 117 | if err != nil { 118 | return nil, fmt.Errorf("Failed to stat '%s'. File may not exist: %s", cmdPath, err) 119 | } 120 | if stat.Mode()&os.ModeSymlink != 0 { 121 | p, err := filepath.EvalSymlinks(cmdPath) 122 | if err != nil { 123 | return nil, fmt.Errorf("Failed to resolve symlink '%s' for executable: %s", cmdPath, err) 124 | } 125 | cmdPath = p 126 | } 127 | 128 | rel, ok, err := up.DetectLatest(slug) 129 | if err != nil { 130 | return nil, err 131 | } 132 | if !ok { 133 | log.Println("No release detected. Current version is considered up-to-date") 134 | return &Release{Version: current}, nil 135 | } 136 | if current.Equals(rel.Version) { 137 | log.Println("Current version", current, "is the latest. Update is not needed") 138 | return rel, nil 139 | } 140 | log.Println("Will update", cmdPath, "to the latest version", rel.Version) 141 | if err := up.UpdateTo(rel, cmdPath); err != nil { 142 | return nil, err 143 | } 144 | return rel, nil 145 | } 146 | 147 | // UpdateSelf updates the running executable itself to the latest version. 148 | // 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. 149 | func (up *Updater) UpdateSelf(current semver.Version, slug string) (*Release, error) { 150 | cmdPath, err := os.Executable() 151 | if err != nil { 152 | return nil, err 153 | } 154 | return up.UpdateCommand(cmdPath, current, slug) 155 | } 156 | 157 | // UpdateTo downloads an executable from assetURL and replace the current binary with the downloaded one. 158 | // 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, 159 | // this function is not available to update a release for private repositories. 160 | // cmdPath is a file path to command executable. 161 | func UpdateTo(assetURL, cmdPath string) error { 162 | up := DefaultUpdater() 163 | src, err := up.downloadDirectlyFromURL(assetURL) 164 | if err != nil { 165 | return err 166 | } 167 | defer src.Close() 168 | return uncompressAndUpdate(src, assetURL, cmdPath) 169 | } 170 | 171 | // UpdateCommand updates a given command binary to the latest version. 172 | // This function is a shortcut version of updater.UpdateCommand. 173 | func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { 174 | return DefaultUpdater().UpdateCommand(cmdPath, current, slug) 175 | } 176 | 177 | // UpdateSelf updates the running executable itself to the latest version. 178 | // This function is a shortcut version of updater.UpdateSelf. 179 | func UpdateSelf(current semver.Version, slug string) (*Release, error) { 180 | return DefaultUpdater().UpdateSelf(current, slug) 181 | } 182 | -------------------------------------------------------------------------------- /selfupdate/detect.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "runtime" 7 | "strings" 8 | 9 | "github.com/blang/semver" 10 | "github.com/google/go-github/github" 11 | ) 12 | 13 | var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`) 14 | 15 | func findAssetFromRelease(rel *github.RepositoryRelease, 16 | suffixes []string, targetVersion string, filters []*regexp.Regexp) (*github.ReleaseAsset, semver.Version, bool) { 17 | 18 | if targetVersion != "" && targetVersion != rel.GetTagName() { 19 | log.Println("Skip", rel.GetTagName(), "not matching to specified version", targetVersion) 20 | return nil, semver.Version{}, false 21 | } 22 | 23 | if targetVersion == "" && rel.GetDraft() { 24 | log.Println("Skip draft version", rel.GetTagName()) 25 | return nil, semver.Version{}, false 26 | } 27 | if targetVersion == "" && rel.GetPrerelease() { 28 | log.Println("Skip pre-release version", rel.GetTagName()) 29 | return nil, semver.Version{}, false 30 | } 31 | 32 | verText := rel.GetTagName() 33 | indices := reVersion.FindStringIndex(verText) 34 | if indices == nil { 35 | log.Println("Skip version not adopting semver", verText) 36 | return nil, semver.Version{}, false 37 | } 38 | if indices[0] > 0 { 39 | log.Println("Strip prefix of version", verText[:indices[0]], "from", verText) 40 | verText = verText[indices[0]:] 41 | } 42 | 43 | // If semver cannot parse the version text, it means that the text is not adopting 44 | // the semantic versioning. So it should be skipped. 45 | ver, err := semver.Make(verText) 46 | if err != nil { 47 | log.Println("Failed to parse a semantic version", verText) 48 | return nil, semver.Version{}, false 49 | } 50 | 51 | for _, asset := range rel.Assets { 52 | name := asset.GetName() 53 | if len(filters) > 0 { 54 | // if some filters are defined, match them: if any one matches, the asset is selected 55 | matched := false 56 | for _, filter := range filters { 57 | if filter.MatchString(name) { 58 | log.Println("Selected filtered asset", name) 59 | matched = true 60 | break 61 | } 62 | log.Printf("Skipping asset %q not matching filter %v\n", name, filter) 63 | } 64 | if !matched { 65 | continue 66 | } 67 | } 68 | 69 | for _, s := range suffixes { 70 | if strings.HasSuffix(name, s) { // require version, arch etc 71 | // default: assume single artifact 72 | return asset, ver, true 73 | } 74 | } 75 | } 76 | 77 | log.Println("No suitable asset was found in release", rel.GetTagName()) 78 | return nil, semver.Version{}, false 79 | } 80 | 81 | func findValidationAsset(rel *github.RepositoryRelease, validationName string) (*github.ReleaseAsset, bool) { 82 | for _, asset := range rel.Assets { 83 | if asset.GetName() == validationName { 84 | return asset, true 85 | } 86 | } 87 | return nil, false 88 | } 89 | 90 | func findReleaseAndAsset(rels []*github.RepositoryRelease, 91 | targetVersion string, 92 | filters []*regexp.Regexp) (*github.RepositoryRelease, *github.ReleaseAsset, semver.Version, bool) { 93 | // Generate candidates 94 | suffixes := make([]string, 0, 2*7*2) 95 | for _, sep := range []rune{'_', '-'} { 96 | for _, ext := range []string{".zip", ".tar.gz", ".tgz", ".gzip", ".gz", ".tar.xz", ".xz", ""} { 97 | suffix := fmt.Sprintf("%s%c%s%s", runtime.GOOS, sep, runtime.GOARCH, ext) 98 | suffixes = append(suffixes, suffix) 99 | if runtime.GOOS == "windows" { 100 | suffix = fmt.Sprintf("%s%c%s.exe%s", runtime.GOOS, sep, runtime.GOARCH, ext) 101 | suffixes = append(suffixes, suffix) 102 | } 103 | } 104 | } 105 | 106 | var ver semver.Version 107 | var asset *github.ReleaseAsset 108 | var release *github.RepositoryRelease 109 | 110 | // Find the latest version from the list of releases. 111 | // Returned list from GitHub API is in the order of the date when created. 112 | // ref: https://github.com/rhysd/go-github-selfupdate/issues/11 113 | for _, rel := range rels { 114 | if a, v, ok := findAssetFromRelease(rel, suffixes, targetVersion, filters); ok { 115 | // Note: any version with suffix is less than any version without suffix. 116 | // e.g. 0.0.1 > 0.0.1-beta 117 | if release == nil || v.GTE(ver) { 118 | ver = v 119 | asset = a 120 | release = rel 121 | } 122 | } 123 | } 124 | 125 | if release == nil { 126 | log.Println("Could not find any release for", runtime.GOOS, "and", runtime.GOARCH) 127 | return nil, nil, semver.Version{}, false 128 | } 129 | 130 | return release, asset, ver, true 131 | } 132 | 133 | // DetectLatest tries to get the latest version of the repository on GitHub. 'slug' means 'owner/name' formatted string. 134 | // It fetches releases information from GitHub API and find out the latest release with matching the tag names and asset names. 135 | // Drafts and pre-releases are ignored. Assets would be suffixed by the OS name and the arch name such as 'foo_linux_amd64' 136 | // 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. 137 | // So the asset can have a file extension for the corresponding compression format such as '.zip'. 138 | // On Windows, '.exe' also can be contained such as 'foo_windows_amd64.exe.zip'. 139 | func (up *Updater) DetectLatest(slug string) (release *Release, found bool, err error) { 140 | return up.DetectVersion(slug, "") 141 | } 142 | 143 | // DetectVersion tries to get the given version of the repository on Github. `slug` means `owner/name` formatted string. 144 | // And version indicates the required version. 145 | func (up *Updater) DetectVersion(slug string, version string) (release *Release, found bool, err error) { 146 | repo := strings.Split(slug, "/") 147 | if len(repo) != 2 || repo[0] == "" || repo[1] == "" { 148 | return nil, false, fmt.Errorf("Invalid slug format. It should be 'owner/name': %s", slug) 149 | } 150 | 151 | rels, res, err := up.api.Repositories.ListReleases(up.apiCtx, repo[0], repo[1], nil) 152 | if err != nil { 153 | log.Println("API returned an error response:", err) 154 | if res != nil && res.StatusCode == 404 { 155 | // 404 means repository not found or release not found. It's not an error here. 156 | err = nil 157 | log.Println("API returned 404. Repository or release not found") 158 | } 159 | return nil, false, err 160 | } 161 | 162 | rel, asset, ver, found := findReleaseAndAsset(rels, version, up.filters) 163 | if !found { 164 | return nil, false, nil 165 | } 166 | 167 | url := asset.GetBrowserDownloadURL() 168 | log.Println("Successfully fetched the latest release. tag:", rel.GetTagName(), ", name:", rel.GetName(), ", URL:", rel.GetURL(), ", Asset:", url) 169 | 170 | publishedAt := rel.GetPublishedAt().Time 171 | release = &Release{ 172 | ver, 173 | url, 174 | asset.GetSize(), 175 | asset.GetID(), 176 | -1, 177 | rel.GetHTMLURL(), 178 | rel.GetBody(), 179 | rel.GetName(), 180 | &publishedAt, 181 | repo[0], 182 | repo[1], 183 | } 184 | 185 | if up.validator != nil { 186 | validationName := asset.GetName() + up.validator.Suffix() 187 | validationAsset, ok := findValidationAsset(rel, validationName) 188 | if !ok { 189 | return nil, false, fmt.Errorf("Failed finding validation file %q", validationName) 190 | } 191 | release.ValidationAssetID = validationAsset.GetID() 192 | } 193 | 194 | return release, true, nil 195 | } 196 | 197 | // DetectLatest detects the latest release of the slug (owner/repo). 198 | // This function is a shortcut version of updater.DetectLatest() method. 199 | func DetectLatest(slug string) (*Release, bool, error) { 200 | return DefaultUpdater().DetectLatest(slug) 201 | } 202 | 203 | // DetectVersion detects the given release of the slug (owner/repo) from its version. 204 | func DetectVersion(slug string, version string) (*Release, bool, error) { 205 | return DefaultUpdater().DetectVersion(slug, version) 206 | } 207 | -------------------------------------------------------------------------------- /selfupdate/update_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/blang/semver" 12 | ) 13 | 14 | func setupTestBinary() { 15 | if err := exec.Command("go", "build", "./testdata/github-release-test/").Run(); err != nil { 16 | panic(err) 17 | } 18 | } 19 | 20 | func teardownTestBinary() { 21 | bin := "github-release-test" 22 | if runtime.GOOS == "windows" { 23 | bin = "github-release-test.exe" 24 | } 25 | if err := os.Remove(bin); err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | func TestUpdateCommand(t *testing.T) { 31 | if testing.Short() { 32 | t.Skip("skip tests in short mode.") 33 | } 34 | 35 | for _, slug := range []string{ 36 | "rhysd-test/test-release-zip", 37 | "rhysd-test/test-release-tar", 38 | "rhysd-test/test-release-gzip", 39 | "rhysd-test/test-release-tar-xz", 40 | "rhysd-test/test-release-xz", 41 | "rhysd-test/test-release-contain-version", 42 | } { 43 | t.Run(slug, func(t *testing.T) { 44 | setupTestBinary() 45 | defer teardownTestBinary() 46 | latest := semver.MustParse("1.2.3") 47 | prev := semver.MustParse("1.2.2") 48 | rel, err := UpdateCommand("github-release-test", prev, slug) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | if rel.Version.NE(latest) { 53 | t.Error("Version is not latest", rel.Version) 54 | } 55 | bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() 56 | if err != nil { 57 | t.Fatal("Failed to run test binary after update:", err) 58 | } 59 | out := string(bytes) 60 | if out != "v1.2.3\n" { 61 | t.Error("Output from test binary after update is unexpected:", out) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func TestUpdateViaSymlink(t *testing.T) { 68 | if testing.Short() { 69 | t.Skip("skip tests in short mode.") 70 | } 71 | if runtime.GOOS == "windows" && os.Getenv("APPVEYOR") == "" { 72 | t.Skip("skipping because creating symlink on windows requires the root privilege") 73 | } 74 | 75 | setupTestBinary() 76 | defer teardownTestBinary() 77 | exePath := "github-release-test" 78 | symPath := "github-release-test-sym" 79 | if runtime.GOOS == "windows" { 80 | exePath = "github-release-test.exe" 81 | symPath = "github-release-test-sym.exe" 82 | } 83 | if err := os.Symlink(exePath, symPath); err != nil { 84 | t.Fatal(err) 85 | } 86 | defer os.Remove(symPath) 87 | 88 | latest := semver.MustParse("1.2.3") 89 | prev := semver.MustParse("1.2.2") 90 | rel, err := UpdateCommand(symPath, prev, "rhysd-test/test-release-zip") 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | if rel.Version.NE(latest) { 95 | t.Error("Version is not latest", rel.Version) 96 | } 97 | 98 | // Test not symbolic link, but actual physical executable 99 | bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() 100 | if err != nil { 101 | t.Fatal("Failed to run test binary after update:", err) 102 | } 103 | out := string(bytes) 104 | if out != "v1.2.3\n" { 105 | t.Error("Output from test binary after update is unexpected:", out) 106 | } 107 | 108 | s, err := os.Lstat(symPath) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | if s.Mode()&os.ModeSymlink == 0 { 113 | t.Fatalf("%s is not a symlink.", symPath) 114 | } 115 | p, err := filepath.EvalSymlinks(symPath) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | if p != exePath { 120 | t.Fatal("Created symlink no loger points the executable:", p) 121 | } 122 | } 123 | 124 | func TestUpdateBrokenSymlinks(t *testing.T) { 125 | if runtime.GOOS == "windows" && os.Getenv("APPVEYOR") == "" { 126 | t.Skip("skipping because creating symlink on windows requires the root privilege") 127 | } 128 | 129 | // unknown-xxx -> unknown-yyy -> {not existing} 130 | xxx := "unknown-xxx" 131 | yyy := "unknown-yyy" 132 | if runtime.GOOS == "windows" { 133 | xxx = "unknown-xxx.exe" 134 | yyy = "unknown-yyy.exe" 135 | } 136 | if err := os.Symlink("not-existing", yyy); err != nil { 137 | t.Fatal(err) 138 | } 139 | defer os.Remove(yyy) 140 | if err := os.Symlink(yyy, xxx); err != nil { 141 | t.Fatal(err) 142 | } 143 | defer os.Remove(xxx) 144 | 145 | v := semver.MustParse("1.2.2") 146 | for _, p := range []string{yyy, xxx} { 147 | _, err := UpdateCommand(p, v, "owner/repo") 148 | if err == nil { 149 | t.Fatal("Error should occur for unlinked symlink", p) 150 | } 151 | if !strings.Contains(err.Error(), "Failed to resolve symlink") { 152 | t.Fatal("Unexpected error for broken symlink", p, err) 153 | } 154 | } 155 | } 156 | 157 | func TestNotExistingCommandPath(t *testing.T) { 158 | _, err := UpdateCommand("not-existing-command-path", semver.MustParse("1.2.2"), "owner/repo") 159 | if err == nil { 160 | t.Fatal("Not existing command path should cause an error") 161 | } 162 | if !strings.Contains(err.Error(), "File may not exist") { 163 | t.Fatal("Unexpected error for not existing command path", err) 164 | } 165 | } 166 | 167 | func TestNoReleaseFoundForUpdate(t *testing.T) { 168 | v := semver.MustParse("1.0.0") 169 | fake := filepath.FromSlash("./testdata/fake-executable") 170 | rel, err := UpdateCommand(fake, v, "rhysd/misc") 171 | if err != nil { 172 | t.Fatal("No release should not make an error:", err) 173 | } 174 | if rel.Version.NE(v) { 175 | t.Error("No release should return the current version as the latest:", rel.Version) 176 | } 177 | if rel.URL != "" { 178 | t.Error("Browse URL should be empty when no release found:", rel.URL) 179 | } 180 | if rel.AssetURL != "" { 181 | t.Error("Asset URL should be empty when no release found:", rel.AssetURL) 182 | } 183 | if rel.ReleaseNotes != "" { 184 | t.Error("Release notes should be empty when no release found:", rel.ReleaseNotes) 185 | } 186 | } 187 | 188 | func TestCurrentIsTheLatest(t *testing.T) { 189 | if testing.Short() { 190 | t.Skip("skip tests in short mode.") 191 | } 192 | setupTestBinary() 193 | defer teardownTestBinary() 194 | 195 | v := semver.MustParse("1.2.3") 196 | rel, err := UpdateCommand("github-release-test", v, "rhysd-test/test-release-zip") 197 | if err != nil { 198 | t.Fatal(err) 199 | } 200 | if rel.Version.NE(v) { 201 | t.Error("v1.2.3 should be the latest:", rel.Version) 202 | } 203 | if rel.URL == "" { 204 | t.Error("Browse URL should not be empty when release found:", rel.URL) 205 | } 206 | if rel.AssetURL == "" { 207 | t.Error("Asset URL should not be empty when release found:", rel.AssetURL) 208 | } 209 | if rel.ReleaseNotes == "" { 210 | t.Error("Release notes should not be empty when release found:", rel.ReleaseNotes) 211 | } 212 | } 213 | 214 | func TestBrokenBinaryUpdate(t *testing.T) { 215 | if testing.Short() { 216 | t.Skip("skip tests in short mode.") 217 | } 218 | 219 | fake := filepath.FromSlash("./testdata/fake-executable") 220 | _, err := UpdateCommand(fake, semver.MustParse("1.2.2"), "rhysd-test/test-incorrect-release") 221 | if err == nil { 222 | t.Fatal("Error should occur for broken package") 223 | } 224 | if !strings.Contains(err.Error(), "Failed to uncompress .tar.gz file") { 225 | t.Fatal("Unexpected error:", err) 226 | } 227 | } 228 | 229 | func TestInvalidSlugForUpdate(t *testing.T) { 230 | fake := filepath.FromSlash("./testdata/fake-executable") 231 | _, err := UpdateCommand(fake, semver.MustParse("1.0.0"), "rhysd/") 232 | if err == nil { 233 | t.Fatal("Unknown repo should cause an error") 234 | } 235 | if !strings.Contains(err.Error(), "Invalid slug format") { 236 | t.Fatal("Unexpected error:", err) 237 | } 238 | } 239 | 240 | func TestInvalidAssetURL(t *testing.T) { 241 | err := UpdateTo("https://github.com/rhysd/non-existing-repo/releases/download/v1.2.3/foo.zip", "foo") 242 | if err == nil { 243 | t.Fatal("Error should occur for URL not found") 244 | } 245 | if !strings.Contains(err.Error(), "Failed to download a release file") { 246 | t.Fatal("Unexpected error:", err) 247 | } 248 | } 249 | 250 | func TestBrokenAsset(t *testing.T) { 251 | asset := "https://github.com/rhysd-test/test-incorrect-release/releases/download/invalid/broken-zip.zip" 252 | err := UpdateTo(asset, "foo") 253 | if err == nil { 254 | t.Fatal("Error should occur for URL not found") 255 | } 256 | if !strings.Contains(err.Error(), "Failed to uncompress zip file") { 257 | t.Fatal("Unexpected error:", err) 258 | } 259 | } 260 | 261 | func TestBrokenGitHubEnterpriseURL(t *testing.T) { 262 | up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"}) 263 | if err != nil { 264 | t.Fatal(err) 265 | } 266 | err = up.UpdateTo(&Release{AssetURL: "https://example.com"}, "foo") 267 | if err == nil { 268 | t.Fatal("Invalid GitHub Enterprise base URL should raise an error") 269 | } 270 | if !strings.Contains(err.Error(), "Failed to call GitHub Releases API for getting an asset") { 271 | t.Error("Unexpected error occurred:", err) 272 | } 273 | } 274 | 275 | func TestUpdateFromGitHubEnterprise(t *testing.T) { 276 | token := os.Getenv("GITHUB_ENTERPRISE_TOKEN") 277 | base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL") 278 | repo := os.Getenv("GITHUB_ENTERPRISE_REPO") 279 | if token == "" { 280 | t.Skip("because token for GHE is not found") 281 | } 282 | if base == "" { 283 | t.Skip("because base URL for GHE is not found") 284 | } 285 | if repo == "" { 286 | t.Skip("because repo slug for GHE is not found") 287 | } 288 | 289 | setupTestBinary() 290 | defer teardownTestBinary() 291 | 292 | up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base}) 293 | if err != nil { 294 | t.Fatal(err) 295 | } 296 | 297 | latest := semver.MustParse("1.2.3") 298 | prev := semver.MustParse("1.2.2") 299 | rel, err := up.UpdateCommand("github-release-test", prev, repo) 300 | if err != nil { 301 | t.Fatal(err) 302 | } 303 | 304 | if rel.Version.NE(latest) { 305 | t.Error("Version is not latest", rel.Version) 306 | } 307 | 308 | bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() 309 | if err != nil { 310 | t.Fatal("Failed to run test binary after update:", err) 311 | } 312 | 313 | out := string(bytes) 314 | if out != "v1.2.3\n" { 315 | t.Error("Output from test binary after update is unexpected:", out) 316 | } 317 | } 318 | 319 | func TestUpdateFromGitHubPrivateRepo(t *testing.T) { 320 | token := os.Getenv("GITHUB_PRIVATE_TOKEN") 321 | if token == "" { 322 | t.Skip("because GITHUB_PRIVATE_TOKEN is not set") 323 | } 324 | 325 | setupTestBinary() 326 | defer teardownTestBinary() 327 | 328 | up, err := NewUpdater(Config{APIToken: token}) 329 | if err != nil { 330 | t.Fatal(err) 331 | } 332 | 333 | latest := semver.MustParse("1.2.3") 334 | prev := semver.MustParse("1.2.2") 335 | rel, err := up.UpdateCommand("github-release-test", prev, "rhysd/private-release-test") 336 | if err != nil { 337 | t.Fatal(err) 338 | } 339 | 340 | if rel.Version.NE(latest) { 341 | t.Error("Version is not latest", rel.Version) 342 | } 343 | 344 | bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() 345 | if err != nil { 346 | t.Fatal("Failed to run test binary after update:", err) 347 | } 348 | 349 | out := string(bytes) 350 | if out != "v1.2.3\n" { 351 | t.Error("Output from test binary after update is unexpected:", out) 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /selfupdate/detect_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/blang/semver" 11 | "github.com/google/go-github/github" 12 | ) 13 | 14 | func TestDetectReleaseWithVersionPrefix(t *testing.T) { 15 | r, ok, err := DetectLatest("rhysd/github-clone-all") 16 | if err != nil { 17 | t.Fatal("Fetch failed:", err) 18 | } 19 | if !ok { 20 | t.Fatal("Failed to detect latest") 21 | } 22 | if r == nil { 23 | t.Fatal("Release detected but nil returned for it") 24 | } 25 | if r.Version.LE(semver.MustParse("2.0.0")) { 26 | t.Error("Incorrect version:", r.Version) 27 | } 28 | if !strings.HasSuffix(r.AssetURL, ".zip") && !strings.HasSuffix(r.AssetURL, ".tar.gz") { 29 | t.Error("Incorrect URL for asset:", r.AssetURL) 30 | } 31 | if r.URL == "" { 32 | t.Error("Document URL should not be empty") 33 | } 34 | if r.ReleaseNotes == "" { 35 | t.Error("Description should not be empty for this repo") 36 | } 37 | if r.Name == "" { 38 | t.Error("Release name is unexpectedly empty") 39 | } 40 | if r.AssetByteSize == 0 { 41 | t.Error("Asset's size is unexpectedly zero") 42 | } 43 | if r.AssetID == 0 { 44 | t.Error("Asset's ID is unexpectedly zero") 45 | } 46 | if r.PublishedAt.IsZero() { 47 | t.Error("Release time is unexpectedly zero") 48 | } 49 | if r.RepoOwner != "rhysd" { 50 | t.Error("Repo owner is not correct:", r.RepoOwner) 51 | } 52 | if r.RepoName != "github-clone-all" { 53 | t.Error("Repo name was not properly detectd:", r.RepoName) 54 | } 55 | } 56 | 57 | func TestDetectVersionExisting(t *testing.T) { 58 | testVersion := "v2.2.0" 59 | r, ok, err := DetectVersion("rhysd/github-clone-all", testVersion) 60 | if err != nil { 61 | t.Fatal("Fetch failed:", err) 62 | } 63 | if !ok { 64 | t.Fatalf("Failed to detect %s", testVersion) 65 | } 66 | if r == nil { 67 | t.Fatal("Release detected but nil returned for it") 68 | } 69 | } 70 | 71 | func TestDetectVersionNotExisting(t *testing.T) { 72 | r, ok, err := DetectVersion("rhysd/github-clone-all", "foobar") 73 | if err != nil { 74 | t.Fatal("Fetch failed:", err) 75 | } 76 | if ok { 77 | t.Fatal("Failed to correctly detect foobar") 78 | } 79 | if r != nil { 80 | t.Fatal("Release not detected but got a returned value for it") 81 | } 82 | } 83 | 84 | func TestDetectReleasesForVariousArchives(t *testing.T) { 85 | for _, tc := range []struct { 86 | slug string 87 | prefix string 88 | }{ 89 | {"rhysd-test/test-release-zip", "v"}, 90 | {"rhysd-test/test-release-tar", "v"}, 91 | {"rhysd-test/test-release-gzip", "v"}, 92 | {"rhysd-test/test-release-xz", "release-v"}, 93 | {"rhysd-test/test-release-tar-xz", "release-"}, 94 | } { 95 | t.Run(tc.slug, func(t *testing.T) { 96 | r, ok, err := DetectLatest(tc.slug) 97 | if err != nil { 98 | t.Fatal("Fetch failed:", err) 99 | } 100 | if !ok { 101 | t.Fatal(tc.slug, "not found") 102 | } 103 | if r == nil { 104 | t.Fatal("Release not detected") 105 | } 106 | if !r.Version.Equals(semver.MustParse("1.2.3")) { 107 | t.Error("") 108 | } 109 | url := fmt.Sprintf("https://github.com/%s/releases/tag/%s1.2.3", tc.slug, tc.prefix) 110 | if r.URL != url { 111 | t.Error("URL is not correct. Want", url, "but got", r.URL) 112 | } 113 | if r.ReleaseNotes == "" { 114 | t.Error("Release note is unexpectedly empty") 115 | } 116 | if !strings.HasPrefix(r.AssetURL, fmt.Sprintf("https://github.com/%s/releases/download/%s1.2.3/", tc.slug, tc.prefix)) { 117 | t.Error("Unexpected asset URL:", r.AssetURL) 118 | } 119 | if r.Name == "" { 120 | t.Error("Release name is unexpectedly empty") 121 | } 122 | if r.AssetByteSize == 0 { 123 | t.Error("Asset's size is unexpectedly zero") 124 | } 125 | if r.AssetID == 0 { 126 | t.Error("Asset's ID is unexpectedly zero") 127 | } 128 | if r.PublishedAt.IsZero() { 129 | t.Error("Release time is unexpectedly zero") 130 | } 131 | if r.RepoOwner != "rhysd-test" { 132 | t.Error("Repo owner should be rhysd-test:", r.RepoOwner) 133 | } 134 | if !strings.HasPrefix(r.RepoName, "test-release-") { 135 | t.Error("Repo name was not properly detectd:", r.RepoName) 136 | } 137 | }) 138 | } 139 | } 140 | 141 | func TestDetectReleaseButNoAsset(t *testing.T) { 142 | _, ok, err := DetectLatest("rhysd/clever-f.vim") 143 | if err != nil { 144 | t.Fatal("Fetch failed:", err) 145 | } 146 | if ok { 147 | t.Fatal("When no asset found, result should be marked as 'not found'") 148 | } 149 | } 150 | 151 | func TestDetectNoRelease(t *testing.T) { 152 | _, ok, err := DetectLatest("rhysd/clever-f.vim") 153 | if err != nil { 154 | t.Fatal("Fetch failed:", err) 155 | } 156 | if ok { 157 | t.Fatal("When no release found, result should be marked as 'not found'") 158 | } 159 | } 160 | 161 | func TestInvalidSlug(t *testing.T) { 162 | up := DefaultUpdater() 163 | 164 | for _, slug := range []string{ 165 | "foo", 166 | "/", 167 | "foo/", 168 | "/bar", 169 | "foo/bar/piyo", 170 | } { 171 | _, _, err := up.DetectLatest(slug) 172 | if err == nil { 173 | t.Error(slug, "should be invalid slug") 174 | } 175 | if !strings.Contains(err.Error(), "Invalid slug format") { 176 | t.Error("Unexpected error for", slug, ":", err) 177 | } 178 | } 179 | } 180 | 181 | func TestNonExistingRepo(t *testing.T) { 182 | v, ok, err := DetectLatest("rhysd/non-existing-repo") 183 | if err != nil { 184 | t.Fatal("Non-existing repo should not cause an error:", v) 185 | } 186 | if ok { 187 | t.Fatal("Release for non-existing repo should not be found") 188 | } 189 | } 190 | 191 | func TestNoReleaseFound(t *testing.T) { 192 | _, ok, err := DetectLatest("rhysd/misc") 193 | if err != nil { 194 | t.Fatal("Repo having no release should not cause an error:", err) 195 | } 196 | if ok { 197 | t.Fatal("Repo having no release should not be found") 198 | } 199 | } 200 | 201 | func TestDetectFromBrokenGitHubEnterpriseURL(t *testing.T) { 202 | up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"}) 203 | if err != nil { 204 | t.Fatal(err) 205 | } 206 | _, ok, _ := up.DetectLatest("foo/bar") 207 | if ok { 208 | t.Fatal("Invalid GitHub Enterprise base URL should raise an error") 209 | } 210 | } 211 | 212 | func TestDetectFromGitHubEnterpriseRepo(t *testing.T) { 213 | token := os.Getenv("GITHUB_ENTERPRISE_TOKEN") 214 | base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL") 215 | repo := os.Getenv("GITHUB_ENTERPRISE_REPO") 216 | if token == "" { 217 | t.Skip("because token for GHE is not found") 218 | } 219 | if base == "" { 220 | t.Skip("because base URL for GHE is not found") 221 | } 222 | if repo == "" { 223 | t.Skip("because repo slug for GHE is not found") 224 | } 225 | 226 | up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base}) 227 | if err != nil { 228 | t.Fatal(err) 229 | } 230 | 231 | r, ok, err := up.DetectLatest(repo) 232 | if err != nil { 233 | t.Fatal("Fetch failed:", err) 234 | } 235 | if !ok { 236 | t.Fatal(repo, "not found") 237 | } 238 | if r == nil { 239 | t.Fatal("Release not detected") 240 | } 241 | if !r.Version.Equals(semver.MustParse("1.2.3")) { 242 | t.Error("") 243 | } 244 | } 245 | 246 | func TestFindReleaseAndAsset(t *testing.T) { 247 | EnableLog() 248 | type findReleaseAndAssetFixture struct { 249 | name string 250 | rels *github.RepositoryRelease 251 | targetVersion string 252 | filters []*regexp.Regexp 253 | expectedAsset string 254 | expectedVersion string 255 | expectedFound bool 256 | } 257 | 258 | rel1 := "rel1" 259 | v1 := "1.0.0" 260 | rel11 := "rel11" 261 | v11 := "1.1.0" 262 | asset1 := "asset1.gz" 263 | asset2 := "asset2.gz" 264 | wrongAsset1 := "asset1.yaml" 265 | asset11 := "asset11.gz" 266 | url1 := "https://asset1" 267 | url2 := "https://asset2" 268 | url11 := "https://asset11" 269 | for _, fixture := range []findReleaseAndAssetFixture{ 270 | { 271 | name: "empty fixture", 272 | rels: nil, 273 | targetVersion: "", 274 | filters: nil, 275 | expectedFound: false, 276 | }, 277 | { 278 | name: "find asset, no filters", 279 | rels: &github.RepositoryRelease{ 280 | Name: &rel1, 281 | TagName: &v1, 282 | Assets: []*github.ReleaseAsset{ 283 | { 284 | Name: &asset1, 285 | URL: &url1, 286 | }, 287 | }, 288 | }, 289 | targetVersion: "1.0.0", 290 | expectedAsset: asset1, 291 | expectedVersion: "1.0.0", 292 | expectedFound: true, 293 | }, 294 | { 295 | name: "don't find asset with wrong extension, no filters", 296 | rels: &github.RepositoryRelease{ 297 | Name: &rel11, 298 | TagName: &v11, 299 | Assets: []*github.ReleaseAsset{ 300 | { 301 | Name: &wrongAsset1, 302 | URL: &url11, 303 | }, 304 | }, 305 | }, 306 | targetVersion: "1.1.0", 307 | expectedFound: false, 308 | }, 309 | { 310 | name: "find asset with different name, no filters", 311 | rels: &github.RepositoryRelease{ 312 | Name: &rel11, 313 | TagName: &v11, 314 | Assets: []*github.ReleaseAsset{ 315 | { 316 | Name: &asset1, 317 | URL: &url11, 318 | }, 319 | }, 320 | }, 321 | targetVersion: "1.1.0", 322 | expectedAsset: asset1, 323 | expectedVersion: "1.1.0", 324 | expectedFound: true, 325 | }, 326 | { 327 | name: "find asset, no filters (2)", 328 | rels: &github.RepositoryRelease{ 329 | Name: &rel11, 330 | TagName: &v11, 331 | Assets: []*github.ReleaseAsset{ 332 | { 333 | Name: &asset11, 334 | URL: &url11, 335 | }, 336 | }, 337 | }, 338 | targetVersion: "1.1.0", 339 | expectedAsset: asset11, 340 | expectedVersion: "1.1.0", 341 | filters: nil, 342 | expectedFound: true, 343 | }, 344 | { 345 | name: "find asset, match filter", 346 | rels: &github.RepositoryRelease{ 347 | Name: &rel11, 348 | TagName: &v11, 349 | Assets: []*github.ReleaseAsset{ 350 | { 351 | Name: &asset11, 352 | URL: &url11, 353 | }, 354 | { 355 | Name: &asset1, 356 | URL: &url1, 357 | }, 358 | }, 359 | }, 360 | targetVersion: "1.1.0", 361 | filters: []*regexp.Regexp{regexp.MustCompile("11")}, 362 | expectedAsset: asset11, 363 | expectedVersion: "1.1.0", 364 | expectedFound: true, 365 | }, 366 | { 367 | name: "find asset, match another filter", 368 | rels: &github.RepositoryRelease{ 369 | Name: &rel11, 370 | TagName: &v11, 371 | Assets: []*github.ReleaseAsset{ 372 | { 373 | Name: &asset11, 374 | URL: &url11, 375 | }, 376 | { 377 | Name: &asset1, 378 | URL: &url1, 379 | }, 380 | }, 381 | }, 382 | targetVersion: "1.1.0", 383 | filters: []*regexp.Regexp{regexp.MustCompile("([^1])1{1}([^1])")}, 384 | expectedAsset: asset1, 385 | expectedVersion: "1.1.0", 386 | expectedFound: true, 387 | }, 388 | { 389 | name: "find asset, match any filter", 390 | rels: &github.RepositoryRelease{ 391 | Name: &rel11, 392 | TagName: &v11, 393 | Assets: []*github.ReleaseAsset{ 394 | { 395 | Name: &asset11, 396 | URL: &url11, 397 | }, 398 | { 399 | Name: &asset2, 400 | URL: &url2, 401 | }, 402 | }, 403 | }, 404 | targetVersion: "1.1.0", 405 | filters: []*regexp.Regexp{ 406 | regexp.MustCompile("([^1])1{1}([^1])"), 407 | regexp.MustCompile("([^1])2{1}([^1])"), 408 | }, 409 | expectedAsset: asset2, 410 | expectedVersion: "1.1.0", 411 | expectedFound: true, 412 | }, 413 | { 414 | name: "find asset, match no filter", 415 | rels: &github.RepositoryRelease{ 416 | Name: &rel11, 417 | TagName: &v11, 418 | Assets: []*github.ReleaseAsset{ 419 | { 420 | Name: &asset11, 421 | URL: &url11, 422 | }, 423 | { 424 | Name: &asset2, 425 | URL: &url2, 426 | }, 427 | }, 428 | }, 429 | targetVersion: "1.1.0", 430 | filters: []*regexp.Regexp{ 431 | regexp.MustCompile("another"), 432 | regexp.MustCompile("binary"), 433 | }, 434 | expectedFound: false, 435 | }, 436 | } { 437 | asset, ver, found := findAssetFromRelease(fixture.rels, []string{".gz"}, fixture.targetVersion, fixture.filters) 438 | if fixture.expectedFound { 439 | if !found { 440 | t.Errorf("expected to find an asset for this fixture: %q", fixture.name) 441 | continue 442 | } 443 | if asset.Name == nil { 444 | t.Errorf("invalid asset struct returned from fixture: %q, got: %v", fixture.name, asset) 445 | continue 446 | } 447 | if *asset.Name != fixture.expectedAsset { 448 | t.Errorf("expected asset %q in fixture: %q, got: %s", fixture.expectedAsset, fixture.name, *asset.Name) 449 | continue 450 | } 451 | t.Logf("asset %v, %v", asset, ver) 452 | } else if found { 453 | t.Errorf("expected not to find an asset for this fixture: %q, but got: %v", fixture.name, asset) 454 | } 455 | } 456 | 457 | } 458 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Self-Update Mechanism for Go Commands Using GitHub 2 | ================================================== 3 | 4 | [![GoDoc Badge][]][GoDoc] 5 | [![TravisCI Status][]][TravisCI] 6 | [![AppVeyor Status][]][AppVeyor] 7 | [![Codecov Status][]][Codecov] 8 | 9 | [go-github-selfupdate][] is a Go library to provide a self-update mechanism to command line tools. 10 | 11 | Go does not provide a way to install/update the stable version of tools. By default, Go command line 12 | tools are updated: 13 | 14 | 1. using `go get -u`, but it is not stable because HEAD of the repository is built 15 | 2. using system's package manager, but it is harder to release because of depending on the platform 16 | 3. downloading executables from GitHub release page, but it requires users to download and put it in an executable path manually 17 | 18 | [go-github-selfupdate][] resolves the problem of 3 by detecting the latest release, downloading it and 19 | putting it in `$GOPATH/bin` automatically. 20 | 21 | [go-github-selfupdate][] detects the information of the latest release via [GitHub Releases API][] and 22 | checks the current version. If a newer version than itself is detected, it downloads the released binary from 23 | GitHub and replaces itself. 24 | 25 | - Automatically detect the latest version of released binary on GitHub 26 | - Retrieve the proper binary for the OS and arch where the binary is running 27 | - Update the binary with rollback support on failure 28 | - Tested on Linux, macOS and Windows (using Travis CI and AppVeyor) 29 | - Many archive and compression formats are supported (zip, tar, gzip, xzip) 30 | - Support private repositories 31 | - Support [GitHub Enterprise][] 32 | - Support hash, signature validation (thanks to [@tobiaskohlbau](https://github.com/tobiaskohlbau)) 33 | 34 | And small wrapper CLIs are provided: 35 | 36 | - [detect-latest-release](./cmd/detect-latest-release): Detect the latest release of given GitHub repository from command line 37 | - [go-get-release](./cmd/go-get-release): Like `go get`, but install a release binary from GitHub instead 38 | 39 | [Slide at GoCon 2018 Spring (Japanese)](https://speakerdeck.com/rhysd/go-selfupdate-github-de-turuwozi-ji-atupudetosuru) 40 | 41 | [go-github-selfupdate]: https://github.com/rhysd/go-github-selfupdate 42 | [GitHub Releases API]: https://developer.github.com/v3/repos/releases/ 43 | 44 | 45 | 46 | ## Try Out Example 47 | 48 | Example to understand what this library does is prepared as [CLI](./cmd/selfupdate-example/main.go). 49 | 50 | Install it at first. 51 | 52 | ``` 53 | $ go get -u github.com/rhysd/go-github-selfupdate/cmd/selfupdate-example 54 | ``` 55 | 56 | And check the version by `-version`. `-help` flag is also available to know all flags. 57 | 58 | ``` 59 | $ selfupdate-example -version 60 | ``` 61 | 62 | It should show `v1.2.3`. 63 | 64 | Then run `-selfupdate` 65 | 66 | ``` 67 | $ selfupdate-example -selfupdate 68 | ``` 69 | 70 | It should replace itself and finally show a message containing release notes. 71 | 72 | Please check the binary version is updated to `v1.2.4` with `-version`. The binary is up-to-date. 73 | So running `-selfupdate` again only shows 'Current binary is the latest version'. 74 | 75 | ### Real World Examples 76 | 77 | Following tools are using this library. 78 | 79 | - [dot-github](https://github.com/rhysd/dot-github) 80 | - [dotfiles](https://github.com/rhysd/dotfiles) 81 | - [github-clone-all](https://github.com/rhysd/github-clone-all) 82 | - [pythonbrew](https://github.com/utahta/pythonbrew) 83 | - [akashic](https://github.com/cowlick/akashic) 84 | - [butler](https://github.com/netzkern/butler) 85 | 86 | 87 | 88 | ## Usage 89 | 90 | ### Code Usage 91 | 92 | It provides `selfupdate` package. 93 | 94 | - `selfupdate.UpdateSelf()`: Detect the latest version of itself and run self update. 95 | - `selfupdate.UpdateCommand()`: Detect the latest version of given repository and update given command. 96 | - `selfupdate.DetectLatest()`: Detect the latest version of given repository. 97 | - `selfupdate.DetectVersion()`: Detect the user defined version of given repository. 98 | - `selfupdate.UpdateTo()`: Update given command to the binary hosted on given URL. 99 | - `selfupdate.Updater`: Context manager of self-update process. If you want to customize some behavior 100 | of self-update (e.g. specify API token, use GitHub Enterprise, ...), please make an instance of 101 | `Updater` and use its methods. 102 | 103 | Following is the easiest way to use this package. 104 | 105 | ```go 106 | import ( 107 | "log" 108 | "github.com/blang/semver" 109 | "github.com/rhysd/go-github-selfupdate/selfupdate" 110 | ) 111 | 112 | const version = "1.2.3" 113 | 114 | func doSelfUpdate() { 115 | v := semver.MustParse(version) 116 | latest, err := selfupdate.UpdateSelf(v, "myname/myrepo") 117 | if err != nil { 118 | log.Println("Binary update failed:", err) 119 | return 120 | } 121 | if latest.Version.Equals(v) { 122 | // latest version is the same as current version. It means current binary is up to date. 123 | log.Println("Current binary is the latest version", version) 124 | } else { 125 | log.Println("Successfully updated to version", latest.Version) 126 | log.Println("Release note:\n", latest.ReleaseNotes) 127 | } 128 | } 129 | ``` 130 | 131 | Following asks user to update or not. 132 | 133 | ```go 134 | import ( 135 | "bufio" 136 | "github.com/blang/semver" 137 | "github.com/rhysd/go-github-selfupdate/selfupdate" 138 | "log" 139 | "os" 140 | ) 141 | 142 | const version = "1.2.3" 143 | 144 | func confirmAndSelfUpdate() { 145 | latest, found, err := selfupdate.DetectLatest("owner/repo") 146 | if err != nil { 147 | log.Println("Error occurred while detecting version:", err) 148 | return 149 | } 150 | 151 | v := semver.MustParse(version) 152 | if !found || latest.Version.LTE(v) { 153 | log.Println("Current version is the latest") 154 | return 155 | } 156 | 157 | fmt.Print("Do you want to update to", latest.Version, "? (y/n): ") 158 | input, err := bufio.NewReader(os.Stdin).ReadString('\n') 159 | if err != nil || (input != "y\n" && input != "n\n") { 160 | log.Println("Invalid input") 161 | return 162 | } 163 | if input == "n\n" { 164 | return 165 | } 166 | 167 | exe, err := os.Executable() 168 | if err != nil { 169 | log.Println("Could not locate executable path") 170 | return 171 | } 172 | if err := selfupdate.UpdateTo(latest.AssetURL, exe); err != nil { 173 | log.Println("Error occurred while updating binary:", err) 174 | return 175 | } 176 | log.Println("Successfully updated to version", latest.Version) 177 | } 178 | ``` 179 | 180 | If GitHub API token is set to `[token]` section in `gitconfig` or `$GITHUB_TOKEN` environment variable, 181 | this library will use it to call GitHub REST API. It's useful when reaching rate limits or when using 182 | this library with private repositories. 183 | 184 | Note that `os.Args[0]` is not available since it does not provide a full path to executable. Instead, 185 | please use `os.Executable()`. 186 | 187 | Please see [the documentation page][GoDoc] for more detail. 188 | 189 | This library should work with [GitHub Enterprise][]. To configure API base URL, please setup `Updater` 190 | instance and use its methods instead (actually all functions above are just a shortcuts of methods of an 191 | `Updater` instance). 192 | 193 | Following is an example of usage with GitHub Enterprise. 194 | 195 | ```go 196 | import ( 197 | "log" 198 | "github.com/blang/semver" 199 | "github.com/rhysd/go-github-selfupdate/selfupdate" 200 | ) 201 | 202 | const version = "1.2.3" 203 | 204 | func doSelfUpdate(token string) { 205 | v := semver.MustParse(version) 206 | up, err := selfupdate.NewUpdater(selfupdate.Config{ 207 | APIToken: token, 208 | EnterpriseBaseURL: "https://github.your.company.com/api/v3", 209 | }) 210 | latest, err := up.UpdateSelf(v, "myname/myrepo") 211 | if err != nil { 212 | log.Println("Binary update failed:", err) 213 | return 214 | } 215 | if latest.Version.Equals(v) { 216 | // latest version is the same as current version. It means current binary is up to date. 217 | log.Println("Current binary is the latest version", version) 218 | } else { 219 | log.Println("Successfully updated to version", latest.Version) 220 | log.Println("Release note:\n", latest.ReleaseNotes) 221 | } 222 | } 223 | ``` 224 | 225 | If `APIToken` field is not given, it tries to retrieve API token from `[token]` section of `.gitconfig` 226 | or `$GITHUB_TOKEN` environment variable. If no token is found, it raises an error because GitHub Enterprise 227 | API does not work without authentication. 228 | 229 | If your GitHub Enterprise instance's upload URL is different from the base URL, please also set the `EnterpriseUploadURL` 230 | field. 231 | 232 | 233 | ### Naming Rules of Released Binaries 234 | 235 | go-github-selfupdate assumes that released binaries are put for each combination of platforms and archs. 236 | Binaries for each platform can be easily built using tools like [gox][] 237 | 238 | You need to put the binaries with the following format. 239 | 240 | ``` 241 | {cmd}_{goos}_{goarch}{.ext} 242 | ``` 243 | 244 | `{cmd}` is a name of command. 245 | `{goos}` and `{goarch}` are the platform and the arch type of the binary. 246 | `{.ext}` is a file extension. go-github-selfupdate supports `.zip`, `.gzip`, `.tar.gz` and `.tar.xz`. 247 | You can also use blank and it means binary is not compressed. 248 | 249 | If you compress binary, uncompressed directory or file must contain the executable named `{cmd}`. 250 | 251 | And you can also use `-` for separator instead of `_` if you like. 252 | 253 | For example, if your command name is `foo-bar`, one of followings is expected to be put in release 254 | page on GitHub as binary for platform `linux` and arch `amd64`. 255 | 256 | - `foo-bar_linux_amd64` (executable) 257 | - `foo-bar_linux_amd64.zip` (zip file) 258 | - `foo-bar_linux_amd64.tar.gz` (tar file) 259 | - `foo-bar_linux_amd64.xz` (xzip file) 260 | - `foo-bar-linux-amd64.tar.gz` (`-` is also ok for separator) 261 | 262 | If you compress and/or archive your release asset, it must contain an executable named one of followings: 263 | 264 | - `foo-bar` (only command name) 265 | - `foo-bar_linux_amd64` (full name) 266 | - `foo-bar-linux-amd64` (`-` is also ok for separator) 267 | 268 | To archive the executable directly on Windows, `.exe` can be added before file extension like 269 | `foo-bar_windows_amd64.exe.zip`. 270 | 271 | [gox]: https://github.com/mitchellh/gox 272 | 273 | 274 | ### Naming Rules of Versions (=Git Tags) 275 | 276 | go-github-selfupdate searches binaries' versions via Git tag names (not a release title). 277 | When your tool's version is `1.2.3`, you should use the version number for tag of the Git 278 | repository (i.e. `1.2.3` or `v1.2.3`). 279 | 280 | This library assumes you adopt [semantic versioning][]. It is necessary for comparing versions 281 | systematically. 282 | 283 | Prefix before version number `\d+\.\d+\.\d+` is automatically omitted. For example, `ver1.2.3` or 284 | `release-1.2.3` are also ok. 285 | 286 | Tags which don't contain a version number are ignored (i.e. `nightly`). And releases marked as `pre-release` 287 | are also ignored. 288 | 289 | [semantic versioning]: https://semver.org/ 290 | 291 | 292 | ### Structure of Releases 293 | 294 | In summary, structure of releases on GitHub looks like: 295 | 296 | - `v1.2.0` 297 | - `foo-bar-linux-amd64.tar.gz` 298 | - `foo-bar-linux-386.tar.gz` 299 | - `foo-bar-darwin-amd64.tar.gz` 300 | - `foo-bar-windows-amd64.zip` 301 | - ... (Other binaries for v1.2.0) 302 | - `v1.1.3` 303 | - `foo-bar-linux-amd64.tar.gz` 304 | - `foo-bar-linux-386.tar.gz` 305 | - `foo-bar-darwin-amd64.tar.gz` 306 | - `foo-bar-windows-amd64.zip` 307 | - ... (Other binaries for v1.1.3) 308 | - ... (older versions) 309 | 310 | 311 | ### Hash or Signature Validation 312 | 313 | go-github-selfupdate supports hash or signature validatiom of the downloaded files. It comes 314 | with support for sha256 hashes or ECDSA signatures. In addition to internal functions the 315 | user can implement the `Validator` interface for own validation mechanisms. 316 | 317 | ```go 318 | // Validator represents an interface which enables additional validation of releases. 319 | type Validator interface { 320 | // Validate validates release bytes against an additional asset bytes. 321 | // See SHA2Validator or ECDSAValidator for more information. 322 | Validate(release, asset []byte) error 323 | // Suffix describes the additional file ending which is used for finding the 324 | // additional asset. 325 | Suffix() string 326 | } 327 | ``` 328 | 329 | #### SHA256 330 | 331 | To verify the integrity by SHA256 generate a hash sum and save it within a file which has the 332 | same naming as original file with the suffix `.sha256`. 333 | For e.g. use sha256sum, the file `selfupdate/testdata/foo.zip.sha256` is generated with: 334 | ```shell 335 | sha256sum foo.zip > foo.zip.sha256 336 | ``` 337 | 338 | #### ECDSA 339 | To verify the signature by ECDSA generate a signature and save it within a file which has the 340 | same naming as original file with the suffix `.sig`. 341 | For e.g. use openssl, the file `selfupdate/testdata/foo.zip.sig` is generated with: 342 | ```shell 343 | openssl dgst -sha256 -sign Test.pem -out foo.zip.sig foo.zip 344 | ``` 345 | 346 | go-github-selfupdate makes use of go internal crypto package. Therefore the used private key 347 | has to be compatbile with FIPS 186-3. 348 | 349 | 350 | 351 | ## Development 352 | 353 | ### Running tests 354 | 355 | All library sources are put in `/selfupdate` directory. So you can run tests as following 356 | at the top of the repository: 357 | 358 | ``` 359 | $ go test -v ./selfupdate 360 | ``` 361 | 362 | Some tests are not run without setting a GitHub API token because they call GitHub API too many times. 363 | To run them, please generate an API token and set it to an environment variable. 364 | 365 | ``` 366 | $ export GITHUB_TOKEN="{token generated by you}" 367 | $ go test -v ./selfupdate 368 | ``` 369 | 370 | The above command runs almost all tests and it's enough to check the behavior before creating a pull request. 371 | Some tests are still not tested because they depend on my personal API access token, though; for repositories 372 | on GitHub Enterprise or private repositories on GitHub. 373 | 374 | 375 | ### Debugging 376 | 377 | This library can output logs for debugging. By default, logger is disabled. 378 | You can enable the logger by the following and can know the details of the self update. 379 | 380 | ```go 381 | selfupdate.EnableLog() 382 | ``` 383 | 384 | 385 | ### CI 386 | 387 | Tests run on CIs (Travis CI, Appveyor) are run with the token I generated. However, because of security 388 | reasons, it is not used for the tests for pull requests. In the tests, a GitHub API token is not set and 389 | API rate limit is often exceeding. So please ignore the test failures on creating a pull request. 390 | 391 | 392 | 393 | ## Dependencies 394 | 395 | This library utilizes 396 | - [go-github][] to retrieve the information of releases 397 | - [go-update][] to replace current binary 398 | - [semver][] to compare versions 399 | - [xz][] to support XZ compress format 400 | 401 | > Copyright (c) 2013 The go-github AUTHORS. All rights reserved. 402 | 403 | > Copyright 2015 Alan Shreve 404 | 405 | > Copyright (c) 2014 Benedikt Lang 406 | 407 | > Copyright (c) 2014-2016 Ulrich Kunitz 408 | 409 | [go-github]: https://github.com/google/go-github 410 | [go-update]: https://github.com/inconshreveable/go-update 411 | [semver]: https://github.com/blang/semver 412 | [xz]: https://github.com/ulikunitz/xz 413 | 414 | 415 | 416 | ## What is different from [tj/go-update][]? 417 | 418 | This library's goal is the same as tj/go-update, but it's different in following points. 419 | 420 | tj/go-update: 421 | 422 | - does not support Windows 423 | - only allows `v` for version prefix 424 | - does not ignore pre-release 425 | - has [only a few tests](https://github.com/tj/go-update/blob/master/update_test.go) 426 | - supports Apex store for putting releases 427 | 428 | [tj/go-update]: https://github.com/tj/go-update 429 | 430 | 431 | 432 | ## License 433 | 434 | Distributed under the [MIT License](LICENSE) 435 | 436 | [GoDoc Badge]: https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate?status.svg 437 | [GoDoc]: https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate 438 | [TravisCI Status]: https://travis-ci.org/rhysd/go-github-selfupdate.svg?branch=master 439 | [TravisCI]: https://travis-ci.org/rhysd/go-github-selfupdate 440 | [AppVeyor Status]: https://ci.appveyor.com/api/projects/status/1tpyd9q9tw3ime5u/branch/master?svg=true 441 | [AppVeyor]: https://ci.appveyor.com/project/rhysd/go-github-selfupdate/branch/master 442 | [Codecov Status]: https://codecov.io/gh/rhysd/go-github-selfupdate/branch/master/graph/badge.svg 443 | [Codecov]: https://codecov.io/gh/rhysd/go-github-selfupdate 444 | [GitHub Enterprise]: https://enterprise.github.com/home 445 | --------------------------------------------------------------------------------