# Auto-update — plan Status: **planned, not started.** Blocked on decisions at the bottom of this file. No code written. Ben wants this as a **standalone, reusable Go module**, not something buried in haul — so it can be dropped into dial, haul, and whatever comes next. This doc lives in haul's repo because that's where the conversation started; the module gets its own repo and its own README. The behaviour to copy is the self-updater in [ShippingTracker](https://gitea.apointless.space/bsncubed/ShippingTracker) (`HelloWorld/Updater.vb`). ## Research already done — don't redo this Verified 2026-07-29/30 against the live gitea instance: - The releases API is **readable anonymously** — both `.../repos/bsncubed/ShippingTracker/releases/latest` and `.../repos/bsncubed/haul` return 200 with no token. The updater needs no credentials. - `.../repos/bsncubed/haul/releases` returns `[]`. haul's repo is public; the 404 on `releases/latest` is just "no releases yet", not permissions. - **dial already publishes proper releases.** Tag `0.2.0`, `draft: false`, `prerelease: false`, with six assets: `dial-darwin-amd64`, `dial-darwin-arm64`, `dial-linux-amd64`, `dial-linux-arm64`, `dial-windows-amd64.exe`, and `SHA256SUMS`. - Release JSON exposes `tag_name`, `draft`, `prerelease`, and `assets[]` with `name` and `browser_download_url`. - Gitea serves `go-import` meta tags (``), so `go get gitea.apointless.space/bsncubed/` resolves. ## What ShippingTracker's updater does 1. GET `/api/v1/repos/{owner}/{repo}/releases/latest`; take `tag_name`, match assets by exact filename. 2. Parse the tag into a version — tolerates a leading `v`, pads missing components with `0`. 3. **Silent vs. loud.** A startup check swallows every failure (offline, DNS, timeout, non-200, malformed JSON) with no UI at all. A user-clicked check reports "you're up to date" and surfaces errors. 4. Prompts with current vs. available before doing anything. 5. **Pre-flights write permission** next to the executable, so a read-only install directory fails early rather than half-applying an update. 6. Streams the download to `.new` behind a progress window. 7. Verifies SHA256 against a checksum asset in `sha256sum` format (` `). Absent checksum asset → installs unverified. 8. **Swaps by rename**: running exe → `.old`, `.new` → exe, rolling back if the second rename fails. `.old` deleted on next launch. 9. On any failure, offers to open the releases page in a browser. The rename dance exists because Windows won't delete a *running* binary but will rename one. Linux and macOS also allow renaming a running executable (the inode stays live), so one code path covers all three. ## The module Its own repo — something like `gitea.apointless.space/bsncubed/selfupdate` (name is an open decision). Separate module, separately versioned, imported by each project. ### The single most important constraint: no UI ShippingTracker's updater calls `MessageBox.Show` directly. **A reusable module cannot do that**, because the three consumers could not be more different: | Consumer | UI | | --- | --- | | ShippingTracker | WinForms message boxes | | haul | Fyne GUI, and widgets may only be touched from the UI thread via `fyne.Do` | | dial | Bubble Tea TUI, an Elm-style message loop | So the module is **pure logic plus callbacks**: it decides, downloads, verifies and swaps; the host application does every prompt, progress bar and error dialog. That also makes it trivially testable against `httptest`. ### Sketch ```go type Config struct { BaseURL string // https://gitea.apointless.space Owner, Repo string Current Version AssetName func(goos, goarch string) string // default: --[.exe] ChecksumAsset string // "SHA256SUMS" (dial) or "checksums.txt" (ShippingTracker) HTTPClient *http.Client UserAgent string AllowPrerelease bool } func Check(ctx, cfg) (*Release, error) // fetch, parse tag, pick this platform's asset func (r *Release) Newer(v Version) bool func Download(ctx, r, dst string, onProgress func(done, total int64)) error func Verify(path, checksumURL, assetName string) error func Apply(newPath string) error // rename dance, rollback, chmod 0755 func CleanupOld() error // delete .old; never errors ``` `onProgress` is the seam: haul marshals it through `fyne.Do`, dial turns it into a Bubble Tea message, ShippingTracker-style apps call it directly. ### Configurable because it genuinely varies - **Checksum asset name** — dial uses `SHA256SUMS`, ShippingTracker uses `checksums.txt`. Proof this can't be a constant. - **Asset naming** — a `func(goos, goarch)` hook, defaulting to dial's existing `--[.exe]` convention, which haul should adopt too. - **Forge** — gitea's release JSON is GitHub-shaped, so the same client covers GitHub for free. Worth keeping the fetch behind a small interface. ### Stays with each project Version stamping, the release pipeline, asset naming, and all UI. ## Consumers, in order **dial first — it is not blocked.** Pure Go, cross-compiles trivially, and its `make dist` already produces all five platform binaries and `SHA256SUMS`, which release `0.2.0` already has attached. dial can ship auto-update as soon as the module exists, which makes it the honest test of whether the API is actually reusable. **haul second, and it needs work first:** 1. **haul has no version at all.** `cmd/haul` has no `version` variable, which is why the Makefile omits `-X main.version` (a `-X` against a non-existent symbol is silently a no-op). Without it every check reports "up to date". 2. **haul cannot build its own release assets.** It is cgo, so `make dist` produces the native binary only — no macOS or Windows assets exist to download. Needs `fyne-cross` over Docker (installed) or real machines. 3. **Linux binaries don't travel.** haul links dynamically against `libGL`, `libX11`, `libc` and ten others; a binary built on Ubuntu 24.04 may not start on another distro. It would download, swap itself in, then fail to launch — the worst failure mode an updater has. dial, being static, has no such problem. Options: build against the oldest glibc worth supporting, ship Linux without auto-update, or accept it. ## Security note **A checksum is not a signature.** `SHA256SUMS` sits on the same server as the binary, so it catches corruption and truncation but not a compromised gitea — anyone who can replace one can replace the other. ShippingTracker accepts this. Closing the gap means a minisign/ed25519 signature with the public key compiled into each consumer, roughly 40 extra lines in the module. ## Consuming the module Because the host is self-hosted, dependent projects will likely want: ```sh go env -w GOPRIVATE=gitea.apointless.space/* ``` so the module is fetched straight from gitea rather than through `proxy.golang.org` and the checksum database. Worth confirming in practice — the repo is public, so the proxy path may work unaided. ## Phasing | Phase | Work | | --- | --- | | 0 | Create the module repo; `Check`/`Newer` plus version parsing, tested against `httptest` | | 1 | `Download`/`Verify`/`Apply`/`CleanupOld`, tested in a temp dir | | 2 | **Adopt in dial** — the real proof of reusability, and shippable immediately | | 3 | haul phase A: version stamping (`var version`, `-X`, `haul --version`) | | 4 | haul phase B: release pipeline via fyne-cross, first tagged release | | 5 | haul phase C: wire the module in — silent check on the connect window, a manual "Check for updates" button, progress and error dialogs | | 6 | READMEs: the module's own, plus a section in each consumer | ## Open decisions 1. **Module name and path** — `selfupdate`? `gitup`? `bsncubed/selfupdate`? 2. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent startup checks mean the app contacts gitea on every launch. 3. **Which platforms get auto-update?** Suggestion: exclude haul's Linux build for the dynamic-linking reason above. dial is fine everywhere. 4. **Checksums only, or real signatures?** 5. **Confirm dial goes first** — it's unblocked and validates the API, whereas haul can't produce release assets yet.