package main import ( "context" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/google/go-github/v76/github" "github.com/mholt/archives" ) // Utility to check errors with an identifier func checkError(identifier string, err error) { if err != nil { log.Panicf("Error. %s: %v\n", identifier, err) } } // Compare semantic versions: returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal func compareVersions(v1, v2 string) int { s1 := strings.Split(strings.TrimSpace(v1), ".") s2 := strings.Split(strings.TrimSpace(v2), ".") for i := 0; i < 3; i++ { n1, _ := strconv.Atoi(s1[i]) n2, _ := strconv.Atoi(s2[i]) if n1 > n2 { return 1 } else if n1 < n2 { return -1 } } return 0 } func main() { workingDir, err := os.Getwd() checkError("get-working-dir", err) // --- Configuration owner := "your-github-username" repo := "your-repo-name" appDir := "app" versionFile := filepath.Join(appDir, "internal", "version.txt") githubToken := os.Getenv("GITHUB_TOKEN") client := github.NewClient(nil).WithAuthToken(githubToken) // --- Fetch latest release ctx := context.TODO() release, githubResp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) checkError("get-latest-release", err) log.Printf("GitHub request status: %d", githubResp.StatusCode) // --- Check local version doesAppExist := false var localTag string if _, err = os.Stat(appDir); err == nil { data, err := os.ReadFile(versionFile) checkError("read-version-file", err) localTag = string(data) doesAppExist = true } remoteTag := release.GetTagName() if remoteTag == "" { log.Panicln("Remote tag is empty. Aborting.") } if doesAppExist && compareVersions(remoteTag, localTag) == -1 { log.Printf("No newer version found. Remote: %s, Local: %s", remoteTag, localTag) return } // --- Download release zipball zipURL := release.GetZipballURL() if zipURL == "" { log.Fatalln("Zipball URL is empty. Aborting.") return } req, err := http.NewRequestWithContext(ctx, http.MethodGet, zipURL, nil) checkError("create-request", err) req.Header.Set("Authorization", "Bearer "+githubToken) req.Header.Set("Accept", "application/zip") resp, err := http.DefaultClient.Do(req) checkError("download-zipball", err) defer resp.Body.Close() if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently { loc := resp.Header.Get("Location") if loc == "" { log.Println("Redirect without location. Aborting.") return } resp.Body.Close() resp, err = http.Get(loc) checkError("follow-redirect", err) defer resp.Body.Close() } if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Fatalf("Download error, status: %d", resp.StatusCode) } log.Printf("Zip downloaded. Storing in memory.") // --- Backup existing app directory if doesAppExist { log.Println("Renaming app/ to temp/") os.Rename(appDir, "temp") } // --- Extract zipball zipPath := "release.zip" out, err := os.Create(zipPath) checkError("save-release-zip", err) _, err = io.Copy(out, resp.Body) out.Close() checkError("copy-zip-to-disk", err) zipFile, err := os.Open(zipPath) checkError("open-release-zip", err) defer func() { zipFile.Close() os.Remove(zipPath) }() handleFile := func(ctx context.Context, file archives.FileInfo) error { rc, err := file.Open() if err != nil { return err } defer rc.Close() outPath := filepath.Join(appDir, file.Name()) if file.IsDir() { return os.MkdirAll(outPath, 0755) } if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return err } outFile, err := os.Create(outPath) if err != nil { return err } defer outFile.Close() _, err = io.Copy(outFile, rc) return err } err = archives.Zip{}.Extract(context.Background(), zipFile, handleFile) if err != nil { log.Println("Extraction error, restoring backup.") if _, err := os.Stat(appDir); err == nil { os.RemoveAll(appDir) } os.Rename("temp", appDir) } if doesAppExist { os.RemoveAll("temp") } log.Println("Update completed successfully.") }