Files
terdut-tui/internal/updater/updater.go
T
Niklas Ye 6fdb4bbbf8
CI / test (push) Successful in 13s
Release / test (push) Successful in 14s
Release / binaries (push) Successful in 1m37s
Move to Gitea: git.ryuvia.com/niklas/terdut-tui
The module path, the CI pipeline and the self-updater all named GitHub. They now
name the Gitea instance everything else already runs on.

The workflows are rewritten rather than translated, for the reason recorded in
ci.yaml: Gitea's runner image is ubuntu:22.04, whose nodejs is Node 12, so no JS
action runs there -- actions/checkout@v4 dies with a SyntaxError before doing
anything. Every step is shell and checkout is a plain clone, which this public
repo needs no credential for. upload-artifact/download-artifact are JS actions
too, and there is no artifact store here, so the job that builds the binaries is
the job that publishes them.

internal/updater keeps its release and asset types unchanged: Gitea's release
payload carries the same tag_name, and its attachments the same name and
browser_download_url, so only the URL, the Accept header and one error string
move. The asset naming in release.yaml is load-bearing for that matching.

This does strand already-installed binaries, which still poll api.github.com.
The GitHub repository is left in place and untouched, so they report themselves
up to date rather than erroring; its last release is the bridge, and crossing it
is a one-time manual download.
2026-08-19 20:41:05 +02:00

153 lines
3.8 KiB
Go

package updater
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
)
// Gitea's release payload carries the same tag_name, and its attachments the same name
// and browser_download_url, so the types below are unchanged from the GitHub original.
//
// A binary installed before the move still polls api.github.com and will never see a
// release published here. That GitHub repository is still in place, so such a build
// reports itself up to date rather than erroring -- its last GitHub release is the
// bridge, and crossing it is a one-time manual download.
const releaseAPI = "https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui/releases/latest"
type release struct {
TagName string `json:"tag_name"`
Assets []asset `json:"assets"`
}
type asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func Run(currentVersion string) error {
if currentVersion == "dev" {
fmt.Println("cannot self-update a dev build")
return nil
}
fmt.Println("Checking for updates…")
rel, err := fetchLatest()
if err != nil {
return fmt.Errorf("fetch release info: %w", err)
}
if rel.TagName == currentVersion {
fmt.Printf("terdut-tui is already up to date (%s)\n", currentVersion)
return nil
}
assetName := fmt.Sprintf("terdut-tui-%s-%s-%s", rel.TagName, runtime.GOOS, runtime.GOARCH)
var downloadURL string
for _, a := range rel.Assets {
if a.Name == assetName {
downloadURL = a.BrowserDownloadURL
break
}
}
if downloadURL == "" {
var names []string
for _, a := range rel.Assets {
names = append(names, a.Name)
}
return fmt.Errorf("no binary found for %s/%s in release %s\navailable: %s",
runtime.GOOS, runtime.GOARCH, rel.TagName, strings.Join(names, ", "))
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve binary path: %w", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return fmt.Errorf("resolve symlink: %w", err)
}
dir := filepath.Dir(exe)
probe, err := os.CreateTemp(dir, ".terdut-tui-update-*")
if err != nil {
return fmt.Errorf("cannot write to %s: %w\nre-run with appropriate permissions", dir, err)
}
probe.Close()
os.Remove(probe.Name())
fmt.Printf("Update terdut-tui %s → %s? [y/N] ", currentVersion, rel.TagName)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
if strings.ToLower(strings.TrimSpace(answer)) != "y" {
fmt.Println("Update cancelled.")
return nil
}
fmt.Printf("Downloading %s…\n", assetName)
tmp, err := os.CreateTemp(dir, ".terdut-tui-update-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() {
tmp.Close()
os.Remove(tmpName)
}()
resp, err := http.Get(downloadURL) //nolint:gosec
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download: unexpected status %s", resp.Status)
}
if _, err := io.Copy(tmp, resp.Body); err != nil {
return fmt.Errorf("write binary: %w", err)
}
tmp.Close()
if err := os.Chmod(tmpName, 0755); err != nil {
return fmt.Errorf("chmod: %w", err)
}
if err := os.Rename(tmpName, exe); err != nil {
return fmt.Errorf("replace binary: %w", err)
}
fmt.Printf("Updated to %s.\n", rel.TagName)
return nil
}
func fetchLatest() (*release, error) {
req, err := http.NewRequest(http.MethodGet, releaseAPI, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Gitea API returned %s", resp.Status)
}
var rel release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, err
}
return &rel, nil
}