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 1540f08624
10 changed files with 619 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
# Copy to .env (gitignored); the Makefile loads it automatically.
# Access token with write:package scope. Required for `make release`.
GITEA_TOKEN=
# Optional overrides (defaults shown).
# GITEA_URL=https://git.simon-franken.de
# GITEA_OWNER=simonfranken
+2
View File
@@ -22,6 +22,8 @@ go run ./cmd/submarine [dir] # run on a repository (default: cwd)
go run ./cmd/submarine --mock # run on mock data
go run ./cmd/submarine --no-fetch [dir] # without background fetch
scripts/make-fixture.sh DIR # build a test superproject in DIR
make dist # cross-compile into dist/
GITEA_TOKEN=… make release # publish a tagged release (outward-facing)
go vet ./...
go test ./...
gofmt -l . # must print nothing
+65
View File
@@ -0,0 +1,65 @@
# Build and publish submarine. Releases are built locally and uploaded to the
# Gitea generic package registry (see docs/architecture.md#distribution).
#
# make dist cross-compile into dist/
# GITEA_TOKEN=... make release test, build and publish the tagged HEAD
# Settings from .env (see .env.template); `make VAR=...` on the command line wins.
-include .env
export GITEA_TOKEN
GITEA_URL ?= https://git.simon-franken.de
GITEA_OWNER ?= simonfranken
REGISTRY := $(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
PLATFORMS := linux_amd64 linux_arm64 darwin_amd64 darwin_arm64
BINARIES := $(addprefix dist/submarine_,$(PLATFORMS))
.PHONY: build test vet dist release clean FORCE
build:
go build -ldflags '$(LDFLAGS)' -o submarine ./cmd/submarine
test:
go test ./...
vet:
go vet ./...
@test -z "$$(gofmt -l .)" || { gofmt -l .; echo "gofmt: files need formatting"; exit 1; }
dist: $(BINARIES) dist/checksums.txt
# FORCE: always rebuild, since the version is baked in.
dist/submarine_%: FORCE
@mkdir -p dist
CGO_ENABLED=0 GOOS=$(word 1,$(subst _, ,$*)) GOARCH=$(word 2,$(subst _, ,$*)) \
go build -trimpath -ldflags '$(LDFLAGS)' -o $@ ./cmd/submarine
# Rebuilt every time so it always matches the binaries.
dist/checksums.txt: $(BINARIES)
cd dist && sha256sum $(notdir $(BINARIES)) > checksums.txt
release:
@test -n "$$GITEA_TOKEN" || { echo "GITEA_TOKEN is not set (needs write:package)"; exit 1; }
@echo '$(VERSION)' | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$$' || \
{ echo "VERSION '$(VERSION)' is not a clean tag: tag HEAD as vX.Y.Z with a clean tree"; exit 1; }
@test -z "$$(git status --porcelain)" || { echo "working tree is not clean"; exit 1; }
$(MAKE) clean test vet dist VERSION=$(VERSION)
@set -e; \
for f in $(BINARIES) dist/checksums.txt; do \
echo "upload $$(basename $$f)"; \
curl -fsS -X PUT -H "Authorization: token $$GITEA_TOKEN" \
--upload-file $$f "$(REGISTRY)/submarine/$(VERSION)/$$(basename $$f)" >/dev/null; \
done; \
echo "point latest at $(VERSION)"; \
printf '%s\n' '$(VERSION)' > dist/VERSION; \
curl -sS -X DELETE -H "Authorization: token $$GITEA_TOKEN" \
"$(GITEA_URL)/api/v1/packages/$(GITEA_OWNER)/generic/submarine-latest/latest" >/dev/null || true; \
curl -fsS -X PUT -H "Authorization: token $$GITEA_TOKEN" \
--upload-file dist/VERSION "$(REGISTRY)/submarine-latest/latest/VERSION" >/dev/null; \
echo "released $(VERSION)"
clean:
rm -rf dist submarine
+83 -1
View File
@@ -6,6 +6,8 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -15,13 +17,27 @@ import (
"submarine/internal/submodule/git"
"submarine/internal/submodule/mock"
"submarine/internal/tui"
"submarine/internal/update"
)
// version is set at build time (-ldflags "-X main.version=v1.2.3").
var version = "dev"
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "update":
os.Exit(runUpdate(os.Args[2:]))
case "version", "--version", "-version":
fmt.Println("submarine", version)
return
}
}
useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository")
noFetch := flag.Bool("no-fetch", false, "don't fetch remotes in the background on startup")
flag.Usage = func() {
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]")
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]\n submarine update [--check] [--force]\n submarine version")
flag.PrintDefaults()
}
flag.Parse()
@@ -56,9 +72,75 @@ func main() {
zone.NewGlobal()
defer zone.Close()
notice := checkForUpdate(ctx, *noFetch || *useMock)
p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
select {
case v := <-notice:
if v != "" {
fmt.Fprintf(os.Stderr, "submarine %s is available (you have %s), run: submarine update\n", v, version)
}
default:
}
}
// checkForUpdate looks for a newer release in the background (at most once a
// day, cached) and delivers its version, or "" when there is none. Disabled
// for dev builds, --no-fetch, --mock and SUBMARINE_NO_UPDATE_CHECK.
func checkForUpdate(ctx context.Context, off bool) <-chan string {
out := make(chan string, 1)
cache, err := os.UserCacheDir()
if off || err != nil || !update.Release(version) || os.Getenv("SUBMARINE_NO_UPDATE_CHECK") != "" {
out <- ""
return out
}
go func() {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
v, err := update.New().LatestCached(ctx, filepath.Join(cache, "submarine", "latest-version"))
if err != nil || !update.Newer(version, v) {
v = ""
}
out <- v
}()
return out
}
func runUpdate(args []string) int {
fs := flag.NewFlagSet("update", flag.ExitOnError)
check := fs.Bool("check", false, "only report whether a newer version exists")
force := fs.Bool("force", false, "install the latest version even if not newer (also for dev builds)")
fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
c := update.New()
latest, err := c.Latest(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "submarine: update check failed:", err)
return 1
}
if !*force && !update.Newer(version, latest) {
fmt.Printf("submarine %s is up to date\n", version)
return 0
}
if *check {
fmt.Printf("update available: %s (you have %s)\n", latest, version)
return 0
}
if !update.Release(version) && !*force {
fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest)
return 1
}
exe, err := c.Install(ctx, latest)
if err != nil {
fmt.Fprintln(os.Stderr, "submarine: update failed:", err)
return 1
}
fmt.Printf("updated %s: %s -> %s\n", exe, version, latest)
return 0
}
+10
View File
@@ -15,6 +15,16 @@ bare commit hash.
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
## Install
```sh
curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
```
Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
`~/.local/bin`. Update with `submarine update`. Maintainers publish with
`GITEA_TOKEN=… make release` (see [architecture](architecture.md#distribution)).
## Status
**Prototype.** The UI is complete for browsing. It reads real repositories
+27
View File
@@ -21,6 +21,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
watch.go Watch: polls git metadata for changes
gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency
internal/update/ self-update from the package registry; NO UI imports
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
@@ -167,6 +168,32 @@ Fetch (network) ──writes refs──▶ .git/… ◀──writes refs──
`ansi.Truncate`, which keeps escape sequences and bubblezone markers intact.
- `zone.Scan` runs once, on the root view.
## Distribution
No CI runner: releases are built and published from a developer machine.
- `make dist` cross-compiles `CGO_ENABLED=0` binaries for linux/darwin ×
amd64/arm64 into `dist/` plus `checksums.txt`; the version comes from
`git describe` and is baked in with `-X main.version`.
- `make release` with `GITEA_TOKEN` set, e.g. in `.env` (copy `.env.template`; token needs `write:package`; HEAD must be a
clean `vX.Y.Z` tag) runs tests, builds and uploads to the Gitea generic
registry `https://git.simon-franken.de/api/packages/simonfranken/generic`:
- `submarine/<version>/submarine_<os>_<arch>` and `checksums.txt`
- `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload
never advertises a broken version.
- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects
OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to
`~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`,
`SUBMARINE_BASE_URL` override).
- `submarine update [--check] [--force]` (package `internal/update`) reads
`VERSION`, downloads the asset for the running platform, verifies the
checksum and atomically replaces the real executable (symlinks resolved,
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
- After the TUI exits, a one-line notice is printed if a newer release
exists. The check runs in the background at startup, at most once a day
(cached in the user cache dir) and is off for dev builds, `--no-fetch`,
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`.
## Testing
```sh
+15
View File
@@ -183,3 +183,18 @@ once per submodule at a time and coalesce bursts.
**Consequences.** One mechanism to test and trust. After our fetch the
data updates up to one poll interval later; the header shows "fetched just
now" right away.
## D21 — Distribution: local build, Gitea generic registry, curl | sh
**Decision.** Releases are built locally with `make` and uploaded with an
access token to the Gitea generic package registry. Installation is a
POSIX `install.sh`; `sub` is a symlink to `submarine`. Updates are
`submarine update`, driven by a `latest/VERSION` pointer file.
**Rationale.** The Gitea instance has no working runner. The generic
registry needs no extra hosting and is publicly readable. A symlink keeps
one binary to update. The pointer is uploaded last and lives in its own
package so versions stay clean.
**Consequences.** Integrity is a sha256 from the same host: it catches
corrupt or partial downloads, not a compromised registry (no signatures).
The update check is a deliberate, outside-the-provider network use: only
on `update` and a once-a-day background check that can be disabled.
Executable
+75
View File
@@ -0,0 +1,75 @@
#!/bin/sh
# Install submarine (and the `sub` alias).
# curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
#
# Environment:
# SUBMARINE_VERSION install this version (default: latest)
# SUBMARINE_INSTALL_DIR target directory (default: ~/.local/bin)
# SUBMARINE_BASE_URL registry root (default: the Gitea generic registry)
set -eu
base="${SUBMARINE_BASE_URL:-https://git.simon-franken.de/api/packages/simonfranken/generic}"
dir="${SUBMARINE_INSTALL_DIR:-$HOME/.local/bin}"
die() { echo "install: $*" >&2; exit 1; }
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) die "unsupported OS: $(uname -s) (linux and macOS only)" ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
arm64|aarch64) arch=arm64 ;;
*) die "unsupported architecture: $(uname -m)" ;;
esac
command -v curl >/dev/null 2>&1 || die "curl is required"
if command -v sha256sum >/dev/null 2>&1; then
sha() { sha256sum "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
sha() { shasum -a 256 "$1" | cut -d' ' -f1; }
else
die "sha256sum or shasum is required"
fi
version="${SUBMARINE_VERSION:-}"
if [ -z "$version" ]; then
version="$(curl -fsSL "$base/submarine-latest/latest/VERSION")" || die "cannot determine the latest version"
version="$(printf '%s' "$version" | tr -d '[:space:]')"
fi
case "$version" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) die "invalid version: $version" ;;
esac
asset="submarine_${os}_${arch}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
echo "Installing submarine $version ($os/$arch) to $dir"
curl -fsSL -o "$tmp/$asset" "$base/submarine/$version/$asset" || die "download failed: $asset"
curl -fsSL -o "$tmp/checksums.txt" "$base/submarine/$version/checksums.txt" || die "download failed: checksums.txt"
want="$(awk -v a="$asset" '$2 == a || $2 == "*" a { print $1 }' "$tmp/checksums.txt")"
[ -n "$want" ] || die "no checksum for $asset"
[ "$(sha "$tmp/$asset")" = "$want" ] || die "checksum mismatch for $asset"
mkdir -p "$dir"
chmod +x "$tmp/$asset"
# Stage next to the target, then rename: atomic, and safe if it is running.
cp "$tmp/$asset" "$dir/.submarine.new"
mv -f "$dir/.submarine.new" "$dir/submarine"
if [ -e "$dir/sub" ] && [ "$(readlink "$dir/sub" 2>/dev/null)" != "submarine" ]; then
echo "install: $dir/sub exists and is not ours, leaving it alone (use \`submarine\`)" >&2
else
ln -sf submarine "$dir/sub"
fi
echo "Installed: $dir/submarine (alias: sub)"
case ":$PATH:" in
*":$dir:"*) ;;
*) echo "Note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
esac
echo "Update later with: submarine update"
+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
}
+106
View File
@@ -0,0 +1,106 @@
package update
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewer(t *testing.T) {
cases := []struct {
cur, latest string
want bool
}{
{"v0.1.0", "v0.2.0", true},
{"v0.2.0", "v0.2.0", false},
{"v0.10.0", "v0.9.0", false},
{"v1.0.0", "v0.9.9", false},
{"dev", "v0.1.0", true},
{"v0.1.0-3-gabc", "v0.1.0", true},
{"v0.1.0", "garbage", false},
}
for _, c := range cases {
if got := Newer(c.cur, c.latest); got != c.want {
t.Errorf("Newer(%q, %q) = %v, want %v", c.cur, c.latest, got, c.want)
}
}
}
func registry(t *testing.T, bin, sum string) *Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/submarine-latest/latest/VERSION", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("v0.2.0\n"))
})
mux.HandleFunc("/submarine/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte(sum + " submarine_linux_arm64\n"))
})
mux.HandleFunc("/submarine/v0.2.0/submarine_linux_arm64", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte(bin))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &Client{BaseURL: srv.URL, HTTP: srv.Client(), GOOS: "linux", GOARCH: "arm64"}
}
func sha(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func TestLatest(t *testing.T) {
c := registry(t, "x", sha("x"))
v, err := c.Latest(context.Background())
if err != nil || v != "v0.2.0" {
t.Fatalf("Latest = %q, %v", v, err)
}
}
func TestInstallReplaces(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "submarine")
os.WriteFile(target, []byte("old"), 0o755)
c := registry(t, "new-binary", sha("new-binary"))
if err := c.InstallTo(context.Background(), "v0.2.0", target); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(target)
if string(got) != "new-binary" {
t.Fatalf("target = %q", got)
}
if fi, _ := os.Stat(target); fi.Mode().Perm()&0o111 == 0 {
t.Error("target not executable")
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Errorf("leftover files: %v", entries)
}
}
func TestInstallChecksumMismatch(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "submarine")
os.WriteFile(target, []byte("old"), 0o755)
c := registry(t, "tampered", sha("original"))
err := c.InstallTo(context.Background(), "v0.2.0", target)
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("err = %v", err)
}
if got, _ := os.ReadFile(target); string(got) != "old" {
t.Errorf("target modified: %q", got)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Errorf("temp file left behind: %v", entries)
}
}
func TestInstallMissingAsset(t *testing.T) {
c := registry(t, "x", sha("x"))
c.GOARCH = "riscv64"
err := c.InstallTo(context.Background(), "v0.2.0", filepath.Join(t.TempDir(), "submarine"))
if err == nil {
t.Fatal("expected error")
}
}