cwdio ships two Windows builds: a per-user installer that updates itself, and a portable zip that installs nothing. Auto-update was built for the first and proven end to end on 7 August 2026 — an installed 0.1.1 found 0.1.2 on the feed by itself. The portable build had nothing. For the audience cwdio targets first, who overwhelmingly prefer the no-install build, that was the update story for most users: visit the website, or never find out. ADR 0033 fixed it, and found two other bugs on the way.
Three mechanisms, none of them right
- Squirrel auto-update returns early unless
Update.exesits beside the executable. A portable folder has none, so it never contacts the feed. Correct — Squirrel cannot update an unzipped folder — but it left the population with no signal either. - The minimum-version kill-switch does run on a portable build and even offers “Open downloads”. But it is a warning dialog saying an update is required, and it exists to retire a known-bad release. Spending it on routine releases would nag every user on every version and blunt the one lever held for a real emergency. It was also deliberately inert, pinned at 0.1.0.
- A manual “check for updates” did not exist anywhere. The only callers of the check were inside the updater itself.
The constraint that shaped it
While proving over-the-air updates, a second problem surfaced. The update feed is written during the release workflow, while the GitHub release is still a draft. On 0.1.2 the feed advertised the new version about eleven minutes before its download page existed publicly. Harmless for the Squirrel updater, which fetches the package from the same feed — but a notice aimed at a human sends them to a download page, and sending them early means sending them to a 404.
What shipped
-
A release index. The release workflow generates
version.json({version, url}) from the tag and uploads it to the GitHub release. Generated, never hand-maintained: a checked-in file would have to be bumped in lockstep and would announce the wrong release the first time someone forgot. -
Read from
releases/latest/download/version.json— not the feed.releases/latestresolves only against a published release: while the draft is unpublished, the URL still serves the previous release’s manifest, and it flips the instant a human presses Publish. The GitHub indirection is the publish gate. That is the entire reason the notice reads a different host than the updater does, and a test fails if the URL is ever pointed at the feed. -
A passive notice, as a notification — never a dialog. “A new version exists” is an outcome to report, not a decision to block on (ADR 0028’s tiers): a toast plus a notification-centre entry with a Download action, about thirty seconds after launch and every four hours. Squirrel installs are skipped — they have the real updater, and a toast duplicating its restart dialog would announce something the user does not have to act on.
-
Help ▸ Check for updates, on every build — including the portable one that cannot self-update. Being unable to act on an update is no reason to be unable to ask. On a Squirrel install it also nudges the real updater, so a manual check does not leave you waiting four hours for the app to act on what it just told you.
Three states, not two. current is a success. unknown — offline, a 404, a malformed manifest, an
unpackaged run — is a warning, and must never be rendered as current, or an offline app cheerfully
tells you that you are up to date. Strictly fail-open, like the kill-switch: an unreachable manifest leaves
the app silent. Both gates share one version comparator, so the kill-switch and the notice can never
disagree about which of two versions is newer.
The bug every fresh install had
The same pass caught something older. Squirrel launches the app with --squirrel-install,
--squirrel-updated and similar flags during install and update, and the app is supposed to handle the
event and exit. It was exiting with app.quit(). A quit() before Electron’s ready event does not
stop ready from firing, so the whole startup ran during the install — both service processes spawned, a
window created, the .running crash sentinel written — and was then torn down mid-flight. before-quit,
which clears the sentinel, had already fired. The sentinel survived, and the next launch opened with “It
looks like the app didn’t close properly last time.”
Every fresh install, 0.1.0 through 0.1.2, apologised for a crash that never happened. The fix is one word
— a lifecycle launch ends in app.exit(0) — and it is reproducible from a clean state by running
cwdio.exe --squirrel-install <version> alone and looking for the sentinel. It is now guarded at the
source level, since the branch runs at module scope where a normal test cannot reach it.
What it deliberately does not do
- No self-replacing portable updater. The portable build tells you and points you at the download; it does not pretend to be something it is not.
- No release notes in the notice — the manifest carries no changelog — and no “skip this version” preference yet.
- It does not touch the kill-switch, which is preserved for what it is for. Routine releases have no reason to reach for it.
Package managers remain the better answer where they apply — scoop update and winget upgrade both
notify and install — and the manifests for them exist in the repo but are not yet submitted. This notice
serves the person who downloaded a zip from the website, which today is most people. Details for users are
on the install and update page; what shipped when is in the
changelog.