Add install script, release Makefile and self-update

Releases are built locally (no runner) and uploaded to the Gitea generic
package registry. install.sh installs submarine plus a sub symlink;
`submarine update` replaces the binary after verifying its checksum.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:01:04 +02:00
co-authored by Claude Sonnet 5.5
parent 6e1236d495
commit 633de5aab1
10 changed files with 619 additions and 1 deletions
+228
View File
@@ -0,0 +1,228 @@
// Package update implements self-update from the Gitea generic package
// registry. It has no UI imports; the network is only used on explicit calls.
package update
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// DefaultBaseURL is the registry root; see docs/architecture.md#distribution.
const DefaultBaseURL = "https://git.simon-franken.de/api/packages/simonfranken/generic"
// EnvBaseURL overrides the registry root (tests, manual verification).
const EnvBaseURL = "SUBMARINE_UPDATE_URL"
// maxBinary bounds a download so a bad server cannot fill the disk.
const maxBinary = 256 << 20
// Client talks to the registry.
type Client struct {
BaseURL string
HTTP *http.Client
GOOS string
GOARCH string
}
// New returns a client for the default (or $SUBMARINE_UPDATE_URL) registry.
func New() *Client {
base := os.Getenv(EnvBaseURL)
if base == "" {
base = DefaultBaseURL
}
return &Client{
BaseURL: strings.TrimRight(base, "/"),
HTTP: &http.Client{Timeout: 5 * time.Minute},
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
}
}
// Latest returns the newest released version, e.g. "v0.2.0".
func (c *Client) Latest(ctx context.Context) (string, error) {
b, err := c.get(ctx, c.BaseURL+"/submarine-latest/latest/VERSION", 1<<10)
if err != nil {
return "", err
}
v := strings.TrimSpace(string(b))
if _, ok := parse(v); !ok {
return "", fmt.Errorf("registry returned an invalid version %q", v)
}
return v, nil
}
// Newer reports whether latest is a higher version than current. Builds that
// are not release versions (e.g. "dev") are never considered up to date.
func Newer(current, latest string) bool {
l, ok := parse(latest)
if !ok {
return false
}
c, ok := parse(current)
if !ok {
return true
}
for i := range l {
if l[i] != c[i] {
return l[i] > c[i]
}
}
return false
}
// Release reports whether v is a release version (vX.Y.Z).
func Release(v string) bool { _, ok := parse(v); return ok }
func parse(v string) ([3]int, bool) {
var out [3]int
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
if !strings.HasPrefix(v, "v") || len(parts) != 3 {
return out, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 || p != strconv.Itoa(n) {
return out, false
}
out[i] = n
}
return out, true
}
// Install downloads version for this platform, verifies its checksum and
// replaces the running executable (symlinks resolved) atomically.
func (c *Client) Install(ctx context.Context, version string) (string, error) {
if !Release(version) {
return "", fmt.Errorf("invalid version %q", version)
}
exe, err := os.Executable()
if err != nil {
return "", err
}
if exe, err = filepath.EvalSymlinks(exe); err != nil {
return "", err
}
return exe, c.InstallTo(ctx, version, exe)
}
// InstallTo is Install with an explicit target path.
func (c *Client) InstallTo(ctx context.Context, version, target string) error {
asset := fmt.Sprintf("submarine_%s_%s", c.GOOS, c.GOARCH)
base := c.BaseURL + "/submarine/" + version + "/"
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
if err != nil {
return err
}
want, err := checksumFor(sums, asset)
if err != nil {
return err
}
// Same directory as the target, so the final rename is atomic.
tmp, err := os.CreateTemp(filepath.Dir(target), ".submarine-update-*")
if err != nil {
return fmt.Errorf("cannot write next to %s (permission?): %w", target, err)
}
defer os.Remove(tmp.Name()) // no-op after a successful rename
h := sha256.New()
if err := c.stream(ctx, base+asset, io.MultiWriter(tmp, h)); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if got := hex.EncodeToString(h.Sum(nil)); got != want {
return fmt.Errorf("checksum mismatch for %s: got %s, want %s", asset, got, want)
}
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return err
}
return os.Rename(tmp.Name(), target)
}
func checksumFor(sums []byte, asset string) (string, error) {
sc := bufio.NewScanner(strings.NewReader(string(sums)))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) == 2 && strings.TrimPrefix(f[1], "*") == asset {
return strings.ToLower(f[0]), nil
}
}
return "", fmt.Errorf("no checksum for %s in checksums.txt", asset)
}
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
var buf strings.Builder
if err := c.stream(ctx, url, &limitWriter{&buf, limit}); err != nil {
return nil, err
}
return []byte(buf.String()), nil
}
func (c *Client) stream(ctx context.Context, url string, w io.Writer) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: %s", url, resp.Status)
}
n, err := io.Copy(w, io.LimitReader(resp.Body, maxBinary+1))
if err != nil {
return err
}
if n > maxBinary {
return errors.New("download too large")
}
return nil
}
type limitWriter struct {
w io.Writer
n int64
}
func (l *limitWriter) Write(p []byte) (int, error) {
l.n -= int64(len(p))
if l.n < 0 {
return 0, errors.New("response too large")
}
return l.w.Write(p)
}
// CheckInterval is how often the passive check may hit the network.
const CheckInterval = 24 * time.Hour
// LatestCached is Latest with a file cache: within CheckInterval it returns
// the cached answer without any network access. A failed lookup is cached too,
// so an unreachable registry is not retried on every start.
func (c *Client) LatestCached(ctx context.Context, cacheFile string) (string, error) {
if fi, err := os.Stat(cacheFile); err == nil && time.Since(fi.ModTime()) < CheckInterval {
b, _ := os.ReadFile(cacheFile)
return strings.TrimSpace(string(b)), nil
}
v, err := c.Latest(ctx)
_ = os.MkdirAll(filepath.Dir(cacheFile), 0o755)
_ = os.WriteFile(cacheFile, []byte(v), 0o644) // v is "" on failure
return v, err
}