# 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.