0d938f1f64
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>
182 lines
8.3 KiB
Markdown
182 lines
8.3 KiB
Markdown
# 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
|
|
(`<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
|
|
|
|
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 `<exe>.new` behind a progress window.
|
|
7. Verifies SHA256 against a checksum asset in `sha256sum` format
|
|
(`<hash> <filename>`). 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: <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 —
|
|
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.
|