package selfupdate import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/pkg/errors" ) // Release collects data about a single release on GitHub. type Release struct { Name string `json:"name"` TagName string `json:"tag_name"` Draft bool `json:"draft"` PreRelease bool `json:"prerelease"` PublishedAt time.Time `json:"published_at"` Assets []Asset `json:"assets"` Version string `json:"-"` // set manually in the code } // Asset is a file uploaded and attached to a release. type Asset struct { ID int `json:"id"` Name string `json:"name"` URL string `json:"url"` } func (r Release) String() string { return fmt.Sprintf("%v %v, %d assets", r.TagName, r.PublishedAt.Local().Format("2006-01-02 15:04:05"), len(r.Assets)) } const githubAPITimeout = 30 * time.Second // githubError is returned by the GitHub API, e.g. for rate-limiting. type githubError struct { Message string } // GitHubLatestRelease uses the GitHub API to get information about the latest // release of a repository. func GitHubLatestRelease(ctx context.Context, owner, repo string) (Release, error) { ctx, cancel := context.WithTimeout(ctx, githubAPITimeout) defer cancel() url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return Release{}, err } // pin API version 3 req.Header.Set("Accept", "application/vnd.github.v3+json") res, err := http.DefaultClient.Do(req) if err != nil { return Release{}, err } if res.StatusCode != http.StatusOK { content := res.Header.Get("Content-Type") if strings.Contains(content, "application/json") { // try to decode error message var msg githubError jerr := json.NewDecoder(res.Body).Decode(&msg) if jerr == nil { return Release{}, fmt.Errorf("unexpected status %v (%v) returned, message:\n %v", res.StatusCode, res.Status, msg.Message) } } _ = res.Body.Close() return Release{}, fmt.Errorf("unexpected status %v (%v) returned", res.StatusCode, res.Status) } buf, err := io.ReadAll(res.Body) if err != nil { _ = res.Body.Close() return Release{}, err } err = res.Body.Close() if err != nil { return Release{}, err } var release Release err = json.Unmarshal(buf, &release) if err != nil { return Release{}, err } if release.TagName == "" { return Release{}, errors.New("tag name for latest release is empty") } if !strings.HasPrefix(release.TagName, "v") { return Release{}, errors.Errorf("tag name %q is invalid, does not start with 'v'", release.TagName) } release.Version = release.TagName[1:] return release, nil } func getGithubData(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } // request binary data req.Header.Set("Accept", "application/octet-stream") res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %v (%v) returned", res.StatusCode, res.Status) } buf, err := io.ReadAll(res.Body) if err != nil { _ = res.Body.Close() return nil, err } err = res.Body.Close() if err != nil { return nil, err } return buf, nil } func getGithubDataFile(ctx context.Context, assets []Asset, suffix string, printf func(string, ...interface{})) (filename string, data []byte, err error) { var url string for _, a := range assets { if strings.HasSuffix(a.Name, suffix) { url = a.URL filename = a.Name break } } if url == "" { return "", nil, fmt.Errorf("unable to find file with suffix %v", suffix) } printf("download %v\n", filename) data, err = getGithubData(ctx, url) if err != nil { return "", nil, err } return filename, data, nil }