get-github-release.go 12 KB

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