github.go 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. package update
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "strings"
  9. )
  10. // Release mirrors the subset of the GitHub releases API response that the
  11. // self-updater consumes.
  12. type Release struct {
  13. TagName string `json:"tag_name"`
  14. Name string `json:"name"`
  15. Body string `json:"body"`
  16. Assets []Asset `json:"assets"`
  17. }
  18. // Asset is a single downloadable artifact attached to a release.
  19. type Asset struct {
  20. Name string `json:"name"`
  21. BrowserDownloadURL string `json:"browser_download_url"`
  22. Size int64 `json:"size"`
  23. }
  24. const githubAPI = "https://api.github.com"
  25. // LatestRelease fetches the newest published release for repo (form
  26. // "owner/name"). A non-empty token is sent as a Bearer header, which is
  27. // required for private repositories and lifts the unauthenticated rate limit.
  28. func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
  29. repo = strings.TrimSpace(repo)
  30. if repo == "" {
  31. return nil, fmt.Errorf("update: repository not configured (set --repo or VOCAT_REPO)")
  32. }
  33. if strings.Count(repo, "/") != 1 {
  34. return nil, fmt.Errorf("update: invalid repository %q (expected owner/name)", repo)
  35. }
  36. req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI+"/repos/"+repo+"/releases/latest", nil)
  37. if err != nil {
  38. return nil, err
  39. }
  40. req.Header.Set("Accept", "application/vnd.github+json")
  41. if token != "" {
  42. req.Header.Set("Authorization", "Bearer "+token)
  43. }
  44. resp, err := http.DefaultClient.Do(req)
  45. if err != nil {
  46. return nil, fmt.Errorf("update: fetch latest release: %w", err)
  47. }
  48. defer resp.Body.Close()
  49. if resp.StatusCode == http.StatusForbidden {
  50. // The releases API returns 403 (not 404) when rate-limited.
  51. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
  52. return nil, fmt.Errorf("update: GitHub API rejected the request (likely rate-limited): %s", strings.TrimSpace(string(body)))
  53. }
  54. if resp.StatusCode == http.StatusNotFound {
  55. return nil, fmt.Errorf("update: no published release found for %s", repo)
  56. }
  57. if resp.StatusCode != http.StatusOK {
  58. return nil, fmt.Errorf("update: GitHub API returned %s", resp.Status)
  59. }
  60. var release Release
  61. if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
  62. return nil, fmt.Errorf("update: decode release JSON: %w", err)
  63. }
  64. return &release, nil
  65. }
  66. // downloadAsset streams a release asset into dst, honoring the request context.
  67. // The token is applied for consistency with the API call (GitHub release assets
  68. // redirect to a pre-signed S3 URL; the token is dropped on redirect, which is
  69. // the expected public-CDN flow).
  70. func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error {
  71. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  72. if err != nil {
  73. return err
  74. }
  75. req.Header.Set("Accept", "application/octet-stream")
  76. if token != "" {
  77. req.Header.Set("Authorization", "Bearer "+token)
  78. }
  79. resp, err := http.DefaultClient.Do(req)
  80. if err != nil {
  81. return fmt.Errorf("update: download asset: %w", err)
  82. }
  83. defer resp.Body.Close()
  84. if resp.StatusCode != http.StatusOK {
  85. return fmt.Errorf("update: asset download returned %s", resp.Status)
  86. }
  87. if _, err := io.Copy(dst, resp.Body); err != nil {
  88. return fmt.Errorf("update: read asset body: %w", err)
  89. }
  90. return nil
  91. }