mirror of
https://github.com/lukaszraczylo/semver-generator.git
synced 2026-06-06 22:53:46 +00:00
360 lines
9.3 KiB
Go
360 lines
9.3 KiB
Go
package utils
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// GitHub API endpoint for latest release
|
|
githubReleasesURL = "https://api.github.com/repos/lukaszraczylo/semver-generator/releases/latest"
|
|
// Request timeout for HTTP requests
|
|
requestTimeout = 10 * time.Second
|
|
)
|
|
|
|
// ReleaseInfo contains information about a GitHub release
|
|
type ReleaseInfo struct {
|
|
TagName string `json:"tag_name"`
|
|
HTMLURL string `json:"html_url"`
|
|
Name string `json:"name"`
|
|
Assets []ReleaseAsset `json:"assets"`
|
|
}
|
|
|
|
// ReleaseAsset contains information about a release asset
|
|
type ReleaseAsset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
}
|
|
|
|
// httpClient is the HTTP client used for requests (allows mocking in tests)
|
|
var httpClient = &http.Client{
|
|
Timeout: requestTimeout,
|
|
}
|
|
|
|
// CheckLatestRelease checks for the latest release version using REST API
|
|
// Returns the latest version tag and true if successful, empty string and false otherwise
|
|
func CheckLatestRelease() (string, bool) {
|
|
release, err := fetchLatestRelease(context.Background())
|
|
if err != nil {
|
|
Debug("Unable to check latest release", map[string]interface{}{"error": err.Error()})
|
|
return "", false
|
|
}
|
|
|
|
version := normalizeVersion(release.TagName)
|
|
return version, true
|
|
}
|
|
|
|
// UpdatePackage downloads and installs the latest version
|
|
func UpdatePackage() bool {
|
|
Info("Checking for updates", nil)
|
|
|
|
release, err := fetchLatestRelease(context.Background())
|
|
if err != nil {
|
|
Error("Unable to fetch latest release", map[string]interface{}{"error": err.Error()})
|
|
return false
|
|
}
|
|
|
|
downloadURL := findBinaryAsset(release.Assets)
|
|
if downloadURL == "" {
|
|
Error("Unable to find binary for current platform", map[string]interface{}{
|
|
"os": runtime.GOOS,
|
|
"arch": runtime.GOARCH,
|
|
})
|
|
return false
|
|
}
|
|
|
|
Info("Downloading update", map[string]interface{}{
|
|
"version": release.TagName,
|
|
"url": downloadURL,
|
|
})
|
|
|
|
// Download to temp file
|
|
tempFile, err := downloadBinary(downloadURL)
|
|
if err != nil {
|
|
Error("Unable to download binary", map[string]interface{}{"error": err.Error()})
|
|
return false
|
|
}
|
|
defer os.Remove(tempFile) // Clean up temp file on failure
|
|
|
|
// Get current binary path
|
|
currentBinary, err := os.Executable()
|
|
if err != nil {
|
|
Error("Unable to get current binary path", map[string]interface{}{"error": err.Error()})
|
|
return false
|
|
}
|
|
|
|
// Replace current binary
|
|
if err := replaceBinary(tempFile, currentBinary); err != nil {
|
|
Error("Unable to replace binary", map[string]interface{}{"error": err.Error()})
|
|
return false
|
|
}
|
|
|
|
Info("Update successful", map[string]interface{}{
|
|
"version": release.TagName,
|
|
})
|
|
return true
|
|
}
|
|
|
|
// fetchLatestRelease fetches the latest release info from GitHub REST API
|
|
func fetchLatestRelease(ctx context.Context) (*ReleaseInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubReleasesURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
|
req.Header.Set("User-Agent", "semver-generator")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var release ReleaseInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &release, nil
|
|
}
|
|
|
|
// findBinaryAsset finds the download URL for the current platform
|
|
func findBinaryAsset(assets []ReleaseAsset) string {
|
|
// Build expected binary name pattern
|
|
// Format: semver-gen-{version}-{os}-{arch}.tar.gz or just semver-gen-{os}-{arch}
|
|
osName := runtime.GOOS
|
|
archName := runtime.GOARCH
|
|
|
|
for _, asset := range assets {
|
|
name := strings.ToLower(asset.Name)
|
|
// Match patterns like "semver-gen-1.0.0-darwin-arm64.tar.gz" or "semver-gen-darwin-arm64"
|
|
if strings.Contains(name, osName) && strings.Contains(name, archName) {
|
|
// Prefer tar.gz archives
|
|
if strings.HasSuffix(name, ".tar.gz") {
|
|
return asset.BrowserDownloadURL
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try to find any matching binary without tar.gz
|
|
for _, asset := range assets {
|
|
name := strings.ToLower(asset.Name)
|
|
if strings.Contains(name, osName) && strings.Contains(name, archName) {
|
|
// Skip checksums
|
|
if strings.Contains(name, "checksum") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".md5") {
|
|
continue
|
|
}
|
|
return asset.BrowserDownloadURL
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// downloadBinary downloads the binary to a temp file and returns the path
|
|
func downloadBinary(url string) (string, error) {
|
|
resp, err := httpClient.Get(url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Create temp file
|
|
tempFile, err := os.CreateTemp("", "semver-generator-update-*")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tempPath := tempFile.Name()
|
|
|
|
// Check if it's a tar.gz archive
|
|
if strings.HasSuffix(url, ".tar.gz") {
|
|
// For tar.gz, we need to extract the binary
|
|
if err := extractTarGz(resp.Body, tempFile); err != nil {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempPath)
|
|
return "", err
|
|
}
|
|
} else {
|
|
// Direct binary download
|
|
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempPath)
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
if err := tempFile.Close(); err != nil {
|
|
_ = os.Remove(tempPath)
|
|
return "", err
|
|
}
|
|
return tempPath, nil
|
|
}
|
|
|
|
// extractTarGz extracts the semver-generator binary from a tar.gz archive
|
|
func extractTarGz(r io.Reader, destFile *os.File) error {
|
|
// For simplicity, we'll download the whole archive to a temp file first,
|
|
// then use tar command to extract. This avoids adding archive/tar dependency.
|
|
|
|
// Create temp archive file
|
|
archiveFile, err := os.CreateTemp("", "semver-generator-archive-*.tar.gz")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
archivePath := archiveFile.Name()
|
|
defer os.Remove(archivePath)
|
|
|
|
if _, err := io.Copy(archiveFile, r); err != nil {
|
|
_ = archiveFile.Close()
|
|
return err
|
|
}
|
|
if err := archiveFile.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Extract using tar command
|
|
extractDir, err := os.MkdirTemp("", "semver-generator-extract-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(extractDir)
|
|
|
|
// Use tar to extract
|
|
cmd := fmt.Sprintf("tar -xzf %s -C %s", archivePath, extractDir)
|
|
if err := runCommand(cmd); err != nil {
|
|
return fmt.Errorf("failed to extract archive: %w", err)
|
|
}
|
|
|
|
// Find the binary in the extracted files
|
|
// Support both new name (semver-generator) and old name (semver-gen) for backwards compatibility
|
|
binaryPath := ""
|
|
entries, err := os.ReadDir(extractDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// First try to find semver-generator (new name)
|
|
for _, entry := range entries {
|
|
if entry.Name() == "semver-generator" {
|
|
binaryPath = fmt.Sprintf("%s/%s", extractDir, entry.Name())
|
|
break
|
|
}
|
|
}
|
|
|
|
// Fallback to semver-gen (old name) for older releases
|
|
if binaryPath == "" {
|
|
for _, entry := range entries {
|
|
if entry.Name() == "semver-gen" {
|
|
binaryPath = fmt.Sprintf("%s/%s", extractDir, entry.Name())
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if binaryPath == "" {
|
|
return fmt.Errorf("binary not found in archive (looked for semver-generator and semver-gen)")
|
|
}
|
|
|
|
// Copy the binary to the destination
|
|
srcFile, err := os.Open(binaryPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
// Seek to beginning of dest file and truncate
|
|
if _, err := destFile.Seek(0, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := destFile.Truncate(0); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := io.Copy(destFile, srcFile); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runCommand runs a shell command
|
|
func runCommand(cmdStr string) error {
|
|
return runCommandFunc(cmdStr)
|
|
}
|
|
|
|
// runCommandFunc is the function used to run commands (allows mocking in tests)
|
|
var runCommandFunc = func(cmdStr string) error {
|
|
cmd := exec.Command("sh", "-c", cmdStr)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// replaceBinary replaces the current binary with the new one
|
|
func replaceBinary(newBinary, currentBinary string) error {
|
|
// Make the new binary executable
|
|
// #nosec G302 -- 0755 is required for executable binaries
|
|
if err := os.Chmod(newBinary, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Rename (atomic on most systems)
|
|
if err := os.Rename(newBinary, currentBinary); err != nil {
|
|
// If rename fails (e.g., cross-device), try copy
|
|
return copyFile(newBinary, currentBinary)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyFile copies a file from src to dst
|
|
// Note: This function is only called internally with controlled paths from
|
|
// os.CreateTemp and os.Executable, not with user-supplied paths.
|
|
func copyFile(src, dst string) error {
|
|
// #nosec G304 -- src is from os.CreateTemp, not user input
|
|
srcFile, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
// #nosec G304 -- dst is from os.Executable, not user input
|
|
dstFile, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Make executable
|
|
// #nosec G302 -- 0755 is required for executable binaries
|
|
return os.Chmod(dst, 0755)
|
|
}
|
|
|
|
// normalizeVersion removes 'v' or 'V' prefix and trims whitespace
|
|
func normalizeVersion(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
v = strings.TrimPrefix(v, "v")
|
|
v = strings.TrimPrefix(v, "V")
|
|
return v
|
|
}
|