docs: replan auto-update as a reusable module

Ben wants this shared across projects rather than living inside haul, so the
plan now describes a standalone module with its own repo.

The consequence that shapes the whole API: it can do no UI of its own.
ShippingTracker called MessageBox directly, but haul is Fyne (widgets only from
the UI thread via fyne.Do) and dial is a Bubble Tea TUI. So the module is pure
logic plus an onProgress callback, and each consumer supplies its own prompts.

Also reorders the work. dial goes first: it is pure Go, already cross-compiles,
and release 0.2.0 already has all five platform binaries and SHA256SUMS
attached — so it can adopt this immediately, whereas haul still can't build its
own release assets. dial using SHA256SUMS where ShippingTracker uses
checksums.txt is why the checksum asset name has to be configurable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:10:10 +10:00
parent 481b998525
commit 0d938f1f64
+138 -100
View File
@@ -1,143 +1,181 @@
# Auto-update for haul — plan # Auto-update — plan
Status: **planned, not started.** Blocked on four decisions (bottom of this Status: **planned, not started.** Blocked on decisions at the bottom of this
file). No code has been written. file. No code written.
The model is the self-updater in 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) [ShippingTracker](https://gitea.apointless.space/bsncubed/ShippingTracker)
(`HelloWorld/Updater.vb`), which haul should behave like. (`HelloWorld/Updater.vb`).
## Research already done — don't redo this ## Research already done — don't redo this
Verified 2026-07-29 against the live gitea instance: Verified 2026-07-29/30 against the live gitea instance:
- `https://gitea.apointless.space/api/v1/repos/bsncubed/ShippingTracker/releases/latest` - The releases API is **readable anonymously** — both
returns **HTTP 200 anonymously**. No token needed. `.../repos/bsncubed/ShippingTracker/releases/latest` and
- `.../repos/bsncubed/haul` also returns **200 anonymously**, and `.../repos/bsncubed/haul` return 200 with no token. The updater needs no
`.../repos/bsncubed/haul/releases` returns `[]`. So haul's repo is public; credentials.
the 404 on `releases/latest` is simply "no releases exist yet", not a - `.../repos/bsncubed/haul/releases` returns `[]`. haul's repo is public; the
permissions problem. The updater will work unauthenticated. 404 on `releases/latest` is just "no releases yet", not permissions.
- Release JSON exposes `tag_name`, `draft`, `prerelease`, and an `assets` array - **dial already publishes proper releases.** Tag `0.2.0`, `draft: false`,
whose entries carry `name` and `browser_download_url`. ShippingTracker's `prerelease: false`, with six assets: `dial-darwin-amd64`,
latest (`1.1.0.2`) attaches exactly two assets: `Shipping Tracker.exe` and `dial-darwin-arm64`, `dial-linux-amd64`, `dial-linux-arm64`,
`checksums.txt`. `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
(`<meta name="go-import" content="gitea.apointless.space/bsncubed/haul git https://…">`),
so `go get gitea.apointless.space/bsncubed/<module>` resolves.
## What ShippingTracker's updater does ## What ShippingTracker's updater does
1. GET `/api/v1/repos/{owner}/{repo}/releases/latest`; take `tag_name`, match 1. GET `/api/v1/repos/{owner}/{repo}/releases/latest`; take `tag_name`, match
assets by exact filename. assets by exact filename.
2. Parse the tag into a version — tolerates a leading `v`, pads missing 2. Parse the tag into a version — tolerates a leading `v`, pads missing
components with `0` (an unset `System.Version` component is `-1`, which components with `0`.
sorts wrong).
3. **Silent vs. loud.** A startup check swallows every failure (offline, DNS, 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 timeout, non-200, malformed JSON) with no UI at all. A user-clicked check
reports "you're up to date" and surfaces errors. reports "you're up to date" and surfaces errors.
4. Prompts with current vs. available before doing anything. 4. Prompts with current vs. available before doing anything.
5. **Pre-flights write permission** next to the executable, so a read-only 5. **Pre-flights write permission** next to the executable, so a read-only
install directory fails early rather than half-applying an update. install directory fails early rather than half-applying an update.
6. Streams the download to `<exe>.new` behind a small progress window. 6. Streams the download to `<exe>.new` behind a progress window.
7. Verifies SHA256 against a `checksums.txt` asset in `sha256sum` format 7. Verifies SHA256 against a checksum asset in `sha256sum` format
(`<hash> <filename>`). If that asset is absent the update still installs, (`<hash> <filename>`). Absent checksum asset → installs unverified.
unverified.
8. **Swaps by rename**: running exe → `.old`, `.new` → exe, rolling back if the 8. **Swaps by rename**: running exe → `.old`, `.new` → exe, rolling back if the
second rename fails. `.old` is deleted on the next launch. second rename fails. `.old` deleted on next launch.
9. On any failure, offers to open the releases page in a browser instead. 9. On any failure, offers to open the releases page in a browser.
## Ports over unchanged
The API shape, the silent/loud split, tag parsing, checksum verification, the
rename-swap with `.old` cleanup, and the manual-download fallback.
The rename dance exists because Windows won't delete a *running* binary but will 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 rename one. Linux and macOS also allow renaming a running executable (the inode
stays live), so a single code path covers all three platforms. stays live), so one code path covers all three.
## Has to change for haul ## The module
1. **haul has no version at all.** ShippingTracker compares against Its own repo — something like `gitea.apointless.space/bsncubed/selfupdate`
`AssemblyVersion`; `cmd/haul` has no equivalent, which is why the Makefile (name is an open decision). Separate module, separately versioned, imported by
deliberately omits `-X main.version` (a `-X` against a non-existent symbol is each project.
silently a no-op). Adding one is a prerequisite — without it every check
reports "up to date".
2. **One asset becomes many.** ShippingTracker is Windows-only, so its
`AssetName` is a single constant. haul must select an asset by
`runtime.GOOS`/`runtime.GOARCH` (`haul-linux-amd64`, `haul-darwin-arm64`,
`haul-windows-amd64.exe`).
3. **The executable bit.** On Unix the downloaded file needs `chmod 0755`.
Nothing in the VB version corresponds to this.
4. **Fyne threading.** `internal/ui` only touches widgets from the UI thread and
returns from I/O via `fyne.Do`. The updater must follow the same shape as
`session.transfer` and `pane.navigateKeepMarks`.
## Blockers — read before starting ### The single most important constraint: no UI
**The release assets can't be built yet.** This gates everything. haul is cgo, ShippingTracker's updater calls `MessageBox.Show` directly. **A reusable module
so `make dist` produces the native binary only; there is currently no way to cannot do that**, because the three consumers could not be more different:
produce macOS or Windows assets from the Linux box. An updater with nothing to
download is pointless, so **the release pipeline must come first**
(`fyne-cross` over Docker, which is installed, or real machines).
**Linux binaries don't travel.** haul links dynamically against `libGL`, | Consumer | UI |
`libX11`, `libc` and ten others. A binary built on Ubuntu 24.04 may not start on | --- | --- |
a different or older distro — it would download, swap itself in, and then fail | ShippingTracker | WinForms message boxes |
to launch, which is the worst failure mode an updater has. ShippingTracker never | haul | Fyne GUI, and widgets may only be touched from the UI thread via `fyne.Do` |
hit this (.NET Framework, stable Windows ABI). Options: build against the oldest | dial | Bubble Tea TUI, an Elm-style message loop |
glibc worth supporting, ship Linux without auto-update, or accept it.
**`checksums.txt` is not a signature.** It lives on the same server as the 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: <bin>-<goos>-<goarch>[.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 <exe>.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
`<binary>-<goos>-<goarch>[.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 — binary, so it catches corruption and truncation but not a compromised gitea —
anyone who can replace one can replace the other. ShippingTracker accepts this. 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 Closing the gap means a minisign/ed25519 signature with the public key compiled
into haul, roughly 40 extra lines. into each consumer, roughly 40 extra lines in the module.
## Proposed shape ## Consuming the module
New package `internal/selfupdate`, UI-free so it can be tested against a fake Because the host is self-hosted, dependent projects will likely want:
HTTP server:
| Piece | Responsibility | ```sh
| --- | --- | go env -w GOPRIVATE=gitea.apointless.space/*
| `Check(ctx) (*Release, error)` | Fetch latest, parse tag, pick this platform's asset | ```
| `Release.Newer(current)` | Version comparison |
| `Download(ctx, rel, progress)` | Stream to `<exe>.new`, report progress |
| `Verify(path, checksumURL)` | SHA256 against `checksums.txt` |
| `Apply(newPath)` | Rename dance, rollback, `chmod` |
| `CleanupOld()` | Delete `<exe>.old`; never errors |
Plus a thin `internal/ui/update.go` for dialogs, reusing `confirmDialog` from so the module is fetched straight from gitea rather than through
`dialogs.go` and a progress dialog modelled on the existing transfer bar. `proxy.golang.org` and the checksum database. Worth confirming in practice —
the repo is public, so the proxy path may work unaided.
Integration points, mirroring ShippingTracker's `Form1_Load` and `Form3`:
- `CleanupOld()` and a silent check when the connect window opens
(`newConnectWindow`).
- A manual "Check for updates" button. The connect window is the natural home —
haul has no settings screen.
Release process: a `make release VERSION=x.y.z` target that builds, writes
`checksums.txt`, and prints the tag/upload steps. Tag as `1.2.0` or `v1.2.0`,
and don't mark it pre-release — `/releases/latest` skips drafts and
pre-releases.
## Phasing ## Phasing
| Phase | Work | | Phase | Work |
| --- | --- | | --- | --- |
| 0 | Version stamping — `var version` in `cmd/haul`, `-X` in the Makefile, `haul --version` | | 0 | Create the module repo; `Check`/`Newer` plus version parsing, tested against `httptest` |
| 1 | **Release pipeline** — cross-builds via fyne-cross, checksums, first tagged release | | 1 | `Download`/`Verify`/`Apply`/`CleanupOld`, tested in a temp dir |
| 2 | `internal/selfupdate` with tests against a fake HTTP server | | 2 | **Adopt in dial** — the real proof of reusability, and shippable immediately |
| 3 | UI wiring — startup silent check, manual button, progress and error dialogs | | 3 | haul phase A: version stamping (`var version`, `-X`, `haul --version`) |
| 4 | README: how updating works, and how to publish a release | | 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 |
Phases 0 and 1 are worth doing regardless — there is currently no way to ship ## Open decisions
haul to anyone at all.
## Open decisions — needed before building 1. **Module name and path**`selfupdate`? `gitup`? `bsncubed/selfupdate`?
2. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent
1. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent startup checks mean the app contacts gitea on every launch.
startup checks mean haul contacts gitea on every launch. 3. **Which platforms get auto-update?** Suggestion: exclude haul's Linux build
2. **Which platforms get auto-update?** Suggestion: exclude Linux at first, for for the dynamic-linking reason above. dial is fine everywhere.
the dynamic-linking reason above. 4. **Checksums only, or real signatures?**
3. **Checksums only, or add signatures?** 5. **Confirm dial goes first** — it's unblocked and validates the API, whereas
4. **Do phase 1 first, or write the updater against ShippingTracker's existing haul can't produce release assets yet.
releases as a test fixture?**