feat: NSIS Windows installer and update check (#32)

- windows/installer.nsi: installs to Program Files, manages system PATH,
  registers uninstaller
- relspec update [--check] [--yes]: compares against the latest release and
  prompts; on Windows downloads and runs the installer
- release workflow builds and uploads relspec-setup-windows-amd64.exe
- make installer-windows
This commit is contained in:
2026-10-03 21:44:08 +02:00
parent b49859537b
commit 02a394c99d
9 changed files with 621 additions and 1 deletions
+115
View File
@@ -0,0 +1,115 @@
// Package updatecheck looks up the latest RelSpec release on the project's
// Gitea instance and compares it against the running version.
package updatecheck
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// DefaultAPIURL is the Gitea endpoint that returns the latest release.
const DefaultAPIURL = "https://git.warky.dev/api/v1/repos/wdevs/relspecgo/releases/latest"
// Asset is a downloadable file attached to a release.
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
// Release is the subset of the Gitea release payload that is needed.
type Release struct {
Tag string `json:"tag_name"`
URL string `json:"html_url"`
Assets []Asset `json:"assets"`
}
// Latest fetches the latest release from apiURL. A nil client uses a client
// with a 10 second timeout.
func Latest(ctx context.Context, client *http.Client, apiURL string) (*Release, error) {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("checking for updates: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("checking for updates: unexpected status %s", resp.Status)
}
var rel Release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("decoding release: %w", err)
}
if rel.Tag == "" {
return nil, fmt.Errorf("release has no tag")
}
return &rel, nil
}
// FindAsset returns the first asset with the given name.
func (r *Release) FindAsset(name string) (Asset, bool) {
for _, a := range r.Assets {
if a.Name == name {
return a, true
}
}
return Asset{}, false
}
// IsNewer reports whether latest is a higher version than current. Versions
// that are not dotted numbers (for example "dev" or a commit hash) are never
// considered outdated, so development builds are not nagged.
func IsNewer(current, latest string) bool {
cur, ok := parseVersion(current)
if !ok {
return false
}
lat, ok := parseVersion(latest)
if !ok {
return false
}
for i := range cur {
if lat[i] != cur[i] {
return lat[i] > cur[i]
}
}
return false
}
// parseVersion parses "v1.2.3" style versions into three numeric parts.
// Missing parts are zero; any pre-release or build suffix is ignored.
func parseVersion(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
if i := strings.IndexAny(v, "-+ "); i >= 0 {
v = v[:i]
}
if v == "" {
return out, false
}
parts := strings.Split(v, ".")
if len(parts) > 3 {
return out, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false
}
out[i] = n
}
return out, true
}
+94
View File
@@ -0,0 +1,94 @@
package updatecheck
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestIsNewer(t *testing.T) {
tests := []struct {
name string
current, latest string
want bool
}{
{"patch bump", "v1.0.85", "v1.0.86", true},
{"minor beats patch", "v1.0.99", "v1.1.0", true},
{"major bump", "v1.9.9", "v2.0.0", true},
{"numeric not lexical", "v1.0.9", "v1.0.10", true},
{"same", "v1.0.85", "v1.0.85", false},
{"older latest", "v1.0.86", "v1.0.85", false},
{"no v prefix", "1.0.1", "v1.0.2", true},
{"short version", "v1.0", "v1.0.1", true},
{"prerelease suffix ignored", "v1.0.1-rc1", "v1.0.2", true},
{"dev build", "dev", "v9.9.9", false},
{"commit hash", "abc1234", "v9.9.9", false},
{"empty current", "", "v1.0.0", false},
{"bad latest", "v1.0.0", "latest", false},
{"too many parts", "v1.0.0.0", "v1.0.1", false},
{"negative", "v1.-1.0", "v1.0.0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q,%q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
})
}
}
func TestLatest(t *testing.T) {
tests := []struct {
name string
status int
body string
wantTag string
wantErr bool
}{
{"ok", 200, `{"tag_name":"v1.2.3","html_url":"https://x/r","assets":[{"name":"a.exe","browser_download_url":"https://x/a.exe"}]}`, "v1.2.3", false},
{"not found", 404, `{}`, "", true},
{"bad json", 200, `{`, "", true},
{"missing tag", 200, `{"html_url":"u"}`, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer srv.Close()
rel, err := Latest(context.Background(), nil, srv.URL)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if err == nil {
if rel.Tag != tt.wantTag {
t.Errorf("tag = %q", rel.Tag)
}
if a, ok := rel.FindAsset("a.exe"); !ok || a.URL != "https://x/a.exe" {
t.Errorf("asset = %+v %v", a, ok)
}
if _, ok := rel.FindAsset("missing"); ok {
t.Error("unexpected asset")
}
}
})
}
}
func TestLatestUnreachable(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
url := srv.URL
srv.Close()
if _, err := Latest(context.Background(), nil, url); err == nil {
t.Error("expected error for unreachable server")
}
}
func TestLatestBadURL(t *testing.T) {
if _, err := Latest(context.Background(), nil, "://bad"); err == nil {
t.Error("expected error for bad URL")
}
}