Wails in Practice #9 Implementing Auto-Update — Delivering a New Version Safely
In #6 we discussed auto-update as strategy only and deferred the implementation. This post builds that deferred piece in real code. A shipped app eventually has a new version, and if users have no way to know, most stay on the old one. But auto-update, done wrong, breaks the signing trust so hard-won in #5 — so we implement it with safety as the priority.
This series is ten posts in two parts.
- Part 1: #1 · #2 · #3 · #4 · #5 · #6
- Part 2: #7 · #8 A testing strategy · #9 Implementing auto-update ← this post · #10 Finishing touches
Stamp a version first #
The starting point for an update check is “what version is the running app?” You could put the version string in source as a constant, but to match #6’s CI flow that makes a release from a tag, it is cleaner to inject the tag as the version at build time. Go’s -ldflags fills in a variable’s value during the build.
package main
// filled at build with -ldflags "-X main.Version=v1.2.0"
var Version = "dev"In CI, the git tag is the version, so having the workflow pass the tag value with -ldflags during the build always keeps the release and the in-app version in agreement. During development it stays at the default dev, letting you skip the update check.
Query the latest version #
GitHub Releases offers an API that reports the latest release. On startup (or when the user clicks “Check for updates”), call it to get the latest tag and compare against the current version.
type release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
func latestRelease(ctx context.Context) (release, error) {
url := "https://api.github.com/repos/OWNER/REPO/releases/latest"
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return release{}, err
}
defer resp.Body.Close()
var r release
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return release{}, err
}
return r, nil
}Do not compare versions as strings — it would wrongly judge v1.10.0 as less than v1.9.0. You must compare by semver rules, and a standard-family library like golang.org/x/mod/semver keeps it safe.
func (a *App) CheckForUpdate() {
if Version == "dev" {
return // do not check on a development build
}
r, err := latestRelease(a.ctx)
if err != nil {
runtime.LogWarning(a.ctx, "update check failed: "+err.Error())
return // pass failures silently — do not block using the app
}
if semver.Compare(r.TagName, Version) > 0 {
runtime.EventsEmit(a.ctx, "update:available", r) // notify the frontend
}
}An update-check failure does not block the app. Even with no network or GitHub not responding, the notes app itself must run fine offline, so a failure is only logged and passed silently.
Notify-style is the safe default #
What to do when there is a new version? #6 showed two paths. The safe default for individuals and small teams is notify-style. Show a banner in the app like “Version v1.2.0 is available — open release,” and when the user clicks it, open the release page carrying the signed and notarized installer in the browser.
import { BrowserOpenURL } from "../wailsjs/runtime/runtime";
EventsOn("update:available", (r) => {
showBanner(`Version ${r.tag_name} is available`, () => {
BrowserOpenURL(r.html_url); // open the release page in the browser
});
});The advantage of this approach is that it does not clash with #5’s signing flow. The user downloads and installs the signed installer as always, so the OS verification works intact. The implementation is simple with few places to break.
Why fully automatic replacement is risky #
Having the app download a new binary and replace itself looks smooth, but in practice it has much to uphold.
- Signature verification: without verifying that the downloaded binary really carries your signature, a man-in-the-middle attack could slip in a malicious binary. Automatic replacement risks eroding, rather than upholding, #5’s trust.
- Permissions and location: install paths (Program Files, Applications) often lack write permission, making it tricky per platform for a file to overwrite itself.
- Rollback: a failure mid-replacement can leave the app unrunnable, so you need a safeguard that reverts to the previous version on failure.
So fully automatic is only for when you are ready to handle verification, permissions, and rollback all together, and only when your user base is large enough that manual prompts become a burden. Until then, notify-style is the answer. Remember too that attaching a proven update framework is often safer than building it yourself.
The version flow at a glance #
Everything so far connects into one. Push git tag v1.2.0 in #6 and CI injects that tag into the app with -ldflags, builds, signs, notarizes, and uploads to the release. The user’s app on startup compares its own version (v1.1.0) with the release’s latest tag (v1.2.0) via semver and, if higher, notifies with a banner. This consistency — one tag running through build, version, and update notification — is why the earlier posts were woven together rather than built separately.
Summary #
- The starting point for an update check is the app’s current version. Inject CI’s tag with
-ldflagsat build to keep the release and app version in agreement. - Query the latest tag with the GitHub Releases API and compare by semver rules, not strings. A check failure does not block the app and passes silently.
- The safe default for individuals and small teams is notify-style. Notify with a banner and open the signed release page, keeping #5’s signature verification intact.
- Fully automatic replacement must handle signature verification, permissions, and rollback, and done wrong it erodes signing trust. Consider it only as scale grows.
- One tag runs through build, version injection, and update notification. Next post finishes the app with settings, dark mode, and data backup.