get-github-release.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. //go:build ignore
  2. // Get the latest release from a github project
  3. //
  4. // If GITHUB_USER and GITHUB_TOKEN are set then these will be used to
  5. // authenticate the request which is useful to avoid rate limits.
  6. package main
  7. import (
  8. "archive/tar"
  9. "compress/bzip2"
  10. "compress/gzip"
  11. "encoding/json"
  12. "flag"
  13. "fmt"
  14. "io"
  15. "log"
  16. "net/http"
  17. "net/url"
  18. "os"
  19. "os/exec"
  20. "path"
  21. "path/filepath"
  22. "regexp"
  23. "runtime"
  24. "strings"
  25. "time"
  26. "github.com/rclone/rclone/lib/rest"
  27. "golang.org/x/net/html"
  28. "golang.org/x/sys/unix"
  29. )
  30. var (
  31. // Flags
  32. install = flag.Bool("install", false, "Install the downloaded package using sudo dpkg -i.")
  33. extract = flag.String("extract", "", "Extract the named executable from the .tar.gz and install into bindir.")
  34. bindir = flag.String("bindir", defaultBinDir(), "Directory to install files downloaded with -extract.")
  35. useAPI = flag.Bool("use-api", false, "Use the API for finding the release instead of scraping the page.")
  36. // Globals
  37. matchProject = regexp.MustCompile(`^([\w-]+)/([\w-]+)$`)
  38. osAliases = map[string][]string{
  39. "darwin": {"macos", "osx"},
  40. }
  41. archAliases = map[string][]string{
  42. "amd64": {"x86_64"},
  43. }
  44. )
  45. // A github release
  46. //
  47. // Made by pasting the JSON into https://mholt.github.io/json-to-go/
  48. type Release struct {
  49. URL string `json:"url"`
  50. AssetsURL string `json:"assets_url"`
  51. UploadURL string `json:"upload_url"`
  52. HTMLURL string `json:"html_url"`
  53. ID int `json:"id"`
  54. TagName string `json:"tag_name"`
  55. TargetCommitish string `json:"target_commitish"`
  56. Name string `json:"name"`
  57. Draft bool `json:"draft"`
  58. Author struct {
  59. Login string `json:"login"`
  60. ID int `json:"id"`
  61. AvatarURL string `json:"avatar_url"`
  62. GravatarID string `json:"gravatar_id"`
  63. URL string `json:"url"`
  64. HTMLURL string `json:"html_url"`
  65. FollowersURL string `json:"followers_url"`
  66. FollowingURL string `json:"following_url"`
  67. GistsURL string `json:"gists_url"`
  68. StarredURL string `json:"starred_url"`
  69. SubscriptionsURL string `json:"subscriptions_url"`
  70. OrganizationsURL string `json:"organizations_url"`
  71. ReposURL string `json:"repos_url"`
  72. EventsURL string `json:"events_url"`
  73. ReceivedEventsURL string `json:"received_events_url"`
  74. Type string `json:"type"`
  75. SiteAdmin bool `json:"site_admin"`
  76. } `json:"author"`
  77. Prerelease bool `json:"prerelease"`
  78. CreatedAt time.Time `json:"created_at"`
  79. PublishedAt time.Time `json:"published_at"`
  80. Assets []struct {
  81. URL string `json:"url"`
  82. ID int `json:"id"`
  83. Name string `json:"name"`
  84. Label string `json:"label"`
  85. Uploader struct {
  86. Login string `json:"login"`
  87. ID int `json:"id"`
  88. AvatarURL string `json:"avatar_url"`
  89. GravatarID string `json:"gravatar_id"`
  90. URL string `json:"url"`
  91. HTMLURL string `json:"html_url"`
  92. FollowersURL string `json:"followers_url"`
  93. FollowingURL string `json:"following_url"`
  94. GistsURL string `json:"gists_url"`
  95. StarredURL string `json:"starred_url"`
  96. SubscriptionsURL string `json:"subscriptions_url"`
  97. OrganizationsURL string `json:"organizations_url"`
  98. ReposURL string `json:"repos_url"`
  99. EventsURL string `json:"events_url"`
  100. ReceivedEventsURL string `json:"received_events_url"`
  101. Type string `json:"type"`
  102. SiteAdmin bool `json:"site_admin"`
  103. } `json:"uploader"`
  104. ContentType string `json:"content_type"`
  105. State string `json:"state"`
  106. Size int `json:"size"`
  107. DownloadCount int `json:"download_count"`
  108. CreatedAt time.Time `json:"created_at"`
  109. UpdatedAt time.Time `json:"updated_at"`
  110. BrowserDownloadURL string `json:"browser_download_url"`
  111. } `json:"assets"`
  112. TarballURL string `json:"tarball_url"`
  113. ZipballURL string `json:"zipball_url"`
  114. Body string `json:"body"`
  115. }
  116. // checks if a path has write access
  117. func writable(path string) bool {
  118. return unix.Access(path, unix.W_OK) == nil
  119. }
  120. // Directory to install releases in by default
  121. //
  122. // Find writable directories on $PATH. Use $GOPATH/bin if that is on
  123. // the path and writable or use the first writable directory which is
  124. // in $HOME or failing that the first writable directory.
  125. //
  126. // Returns "" if none of the above were found
  127. func defaultBinDir() string {
  128. home := os.Getenv("HOME")
  129. var (
  130. bin string
  131. homeBin string
  132. goHomeBin string
  133. gopath = os.Getenv("GOPATH")
  134. )
  135. for _, dir := range strings.Split(os.Getenv("PATH"), ":") {
  136. if writable(dir) {
  137. if strings.HasPrefix(dir, home) {
  138. if homeBin != "" {
  139. homeBin = dir
  140. }
  141. if gopath != "" && strings.HasPrefix(dir, gopath) && goHomeBin == "" {
  142. goHomeBin = dir
  143. }
  144. }
  145. if bin == "" {
  146. bin = dir
  147. }
  148. }
  149. }
  150. if goHomeBin != "" {
  151. return goHomeBin
  152. }
  153. if homeBin != "" {
  154. return homeBin
  155. }
  156. return bin
  157. }
  158. // read the body or an error message
  159. func readBody(in io.Reader) string {
  160. data, err := io.ReadAll(in)
  161. if err != nil {
  162. return fmt.Sprintf("Error reading body: %v", err.Error())
  163. }
  164. return string(data)
  165. }
  166. // Get an asset URL and name
  167. func getAsset(project string, matchName *regexp.Regexp) (string, string) {
  168. url := "https://api.github.com/repos/" + project + "/releases/latest"
  169. log.Printf("Fetching asset info for %q from %q", project, url)
  170. user, pass := os.Getenv("GITHUB_USER"), os.Getenv("GITHUB_TOKEN")
  171. req, err := http.NewRequest("GET", url, nil)
  172. if err != nil {
  173. log.Fatalf("Failed to make http request %q: %v", url, err)
  174. }
  175. if user != "" && pass != "" {
  176. log.Printf("Fetching using GITHUB_USER and GITHUB_TOKEN")
  177. req.SetBasicAuth(user, pass)
  178. }
  179. resp, err := http.DefaultClient.Do(req)
  180. if err != nil {
  181. log.Fatalf("Failed to fetch release info %q: %v", url, err)
  182. }
  183. if resp.StatusCode != http.StatusOK {
  184. log.Printf("Error: %s", readBody(resp.Body))
  185. log.Fatalf("Bad status %d when fetching %q release info: %s", resp.StatusCode, url, resp.Status)
  186. }
  187. var release Release
  188. err = json.NewDecoder(resp.Body).Decode(&release)
  189. if err != nil {
  190. log.Fatalf("Failed to decode release info: %v", err)
  191. }
  192. err = resp.Body.Close()
  193. if err != nil {
  194. log.Fatalf("Failed to close body: %v", err)
  195. }
  196. for _, asset := range release.Assets {
  197. //log.Printf("Finding %s", asset.Name)
  198. if matchName.MatchString(asset.Name) && isOurOsArch(asset.Name) {
  199. return asset.BrowserDownloadURL, asset.Name
  200. }
  201. }
  202. log.Fatalf("Didn't find asset in info")
  203. return "", ""
  204. }
  205. // Get an asset URL and name by scraping the downloads page
  206. //
  207. // This doesn't use the API so isn't rate limited when not using GITHUB login details
  208. func getAssetFromReleasesPage(project string, matchName *regexp.Regexp) (assetURL string, assetName string) {
  209. baseURL := "https://github.com/" + project + "/releases"
  210. log.Printf("Fetching asset info for %q from %q", project, baseURL)
  211. base, err := url.Parse(baseURL)
  212. if err != nil {
  213. log.Fatalf("URL Parse failed: %v", err)
  214. }
  215. resp, err := http.Get(baseURL)
  216. if err != nil {
  217. log.Fatalf("Failed to fetch release info %q: %v", baseURL, err)
  218. }
  219. defer resp.Body.Close()
  220. if resp.StatusCode != http.StatusOK {
  221. log.Printf("Error: %s", readBody(resp.Body))
  222. log.Fatalf("Bad status %d when fetching %q release info: %s", resp.StatusCode, baseURL, resp.Status)
  223. }
  224. doc, err := html.Parse(resp.Body)
  225. if err != nil {
  226. log.Fatalf("Failed to parse web page: %v", err)
  227. }
  228. var walk func(*html.Node)
  229. walk = func(n *html.Node) {
  230. if n.Type == html.ElementNode && n.Data == "a" {
  231. for _, a := range n.Attr {
  232. if a.Key == "href" {
  233. if name := path.Base(a.Val); matchName.MatchString(name) && isOurOsArch(name) {
  234. if u, err := rest.URLJoin(base, a.Val); err == nil {
  235. if assetName == "" {
  236. assetName = name
  237. assetURL = u.String()
  238. }
  239. }
  240. }
  241. break
  242. }
  243. }
  244. }
  245. for c := n.FirstChild; c != nil; c = c.NextSibling {
  246. walk(c)
  247. }
  248. }
  249. walk(doc)
  250. if assetName == "" || assetURL == "" {
  251. log.Fatalf("Didn't find URL in page")
  252. }
  253. return assetURL, assetName
  254. }
  255. // isOurOsArch returns true if s contains our OS and our Arch
  256. func isOurOsArch(s string) bool {
  257. s = strings.ToLower(s)
  258. check := func(base string, aliases map[string][]string) bool {
  259. names := []string{base}
  260. names = append(names, aliases[base]...)
  261. for _, name := range names {
  262. if strings.Contains(s, name) {
  263. return true
  264. }
  265. }
  266. return false
  267. }
  268. return check(runtime.GOARCH, archAliases) && check(runtime.GOOS, osAliases)
  269. }
  270. // get a file for download
  271. func getFile(url, fileName string) {
  272. log.Printf("Downloading %q from %q", fileName, url)
  273. out, err := os.Create(fileName)
  274. if err != nil {
  275. log.Fatalf("Failed to open %q: %v", fileName, err)
  276. }
  277. resp, err := http.Get(url)
  278. if err != nil {
  279. log.Fatalf("Failed to fetch asset %q: %v", url, err)
  280. }
  281. if resp.StatusCode != http.StatusOK {
  282. log.Printf("Error: %s", readBody(resp.Body))
  283. log.Fatalf("Bad status %d when fetching %q asset: %s", resp.StatusCode, url, resp.Status)
  284. }
  285. n, err := io.Copy(out, resp.Body)
  286. if err != nil {
  287. log.Fatalf("Error while downloading: %v", err)
  288. }
  289. err = resp.Body.Close()
  290. if err != nil {
  291. log.Fatalf("Failed to close body: %v", err)
  292. }
  293. err = out.Close()
  294. if err != nil {
  295. log.Fatalf("Failed to close output file: %v", err)
  296. }
  297. log.Printf("Downloaded %q (%d bytes)", fileName, n)
  298. }
  299. // run a shell command
  300. func run(args ...string) {
  301. cmd := exec.Command(args[0], args[1:]...)
  302. cmd.Stdout = os.Stdout
  303. cmd.Stderr = os.Stderr
  304. err := cmd.Run()
  305. if err != nil {
  306. log.Fatalf("Failed to run %v: %v", args, err)
  307. }
  308. }
  309. // Untars fileName from srcFile
  310. func untar(srcFile, fileName, extractDir string) {
  311. f, err := os.Open(srcFile)
  312. if err != nil {
  313. log.Fatalf("Couldn't open tar: %v", err)
  314. }
  315. defer func() {
  316. err := f.Close()
  317. if err != nil {
  318. log.Fatalf("Couldn't close tar: %v", err)
  319. }
  320. }()
  321. var in io.Reader = f
  322. srcExt := filepath.Ext(srcFile)
  323. if srcExt == ".gz" || srcExt == ".tgz" {
  324. gzf, err := gzip.NewReader(f)
  325. if err != nil {
  326. log.Fatalf("Couldn't open gzip: %v", err)
  327. }
  328. in = gzf
  329. } else if srcExt == ".bz2" {
  330. in = bzip2.NewReader(f)
  331. }
  332. tarReader := tar.NewReader(in)
  333. for {
  334. header, err := tarReader.Next()
  335. if err == io.EOF {
  336. break
  337. }
  338. if err != nil {
  339. log.Fatalf("Trouble reading tar file: %v", err)
  340. }
  341. name := header.Name
  342. switch header.Typeflag {
  343. case tar.TypeReg:
  344. baseName := filepath.Base(name)
  345. if baseName == fileName {
  346. outPath := filepath.Join(extractDir, fileName)
  347. out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777)
  348. if err != nil {
  349. log.Fatalf("Couldn't open output file: %v", err)
  350. }
  351. n, err := io.Copy(out, tarReader)
  352. if err != nil {
  353. log.Fatalf("Couldn't write output file: %v", err)
  354. }
  355. if err = out.Close(); err != nil {
  356. log.Fatalf("Couldn't close output: %v", err)
  357. }
  358. log.Printf("Wrote %s (%d bytes) as %q", fileName, n, outPath)
  359. }
  360. }
  361. }
  362. }
  363. func main() {
  364. flag.Parse()
  365. args := flag.Args()
  366. if len(args) != 2 {
  367. log.Fatalf("Syntax: %s <user/project> <name reg exp>", os.Args[0])
  368. }
  369. project, nameRe := args[0], args[1]
  370. if !matchProject.MatchString(project) {
  371. log.Fatalf("Project %q must be in form user/project", project)
  372. }
  373. matchName, err := regexp.Compile(nameRe)
  374. if err != nil {
  375. log.Fatalf("Invalid regexp for name %q: %v", nameRe, err)
  376. }
  377. var assetURL, assetName string
  378. if *useAPI {
  379. assetURL, assetName = getAsset(project, matchName)
  380. } else {
  381. assetURL, assetName = getAssetFromReleasesPage(project, matchName)
  382. }
  383. fileName := filepath.Join(os.TempDir(), assetName)
  384. getFile(assetURL, fileName)
  385. if *install {
  386. log.Printf("Installing %s", fileName)
  387. run("sudo", "dpkg", "--force-bad-version", "-i", fileName)
  388. log.Printf("Installed %s", fileName)
  389. } else if *extract != "" {
  390. if *bindir == "" {
  391. log.Fatalf("Need to set -bindir")
  392. }
  393. log.Printf("Unpacking %s from %s and installing into %s", *extract, fileName, *bindir)
  394. untar(fileName, *extract, *bindir+"/")
  395. }
  396. }