Engineering notes

Every build can learn about an update

How the portable build got an update story: version.json via releases/latest as a publish gate, a passive notice, three states, and the install bug it exposed.

Retells ADR 0033 Published 15 August 2026 View as Markdown

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.exe sits 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

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

  2. Read from releases/latest/download/version.json — not the feed. releases/latest resolves 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.

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

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

Questions

Does the portable build update itself?

No — replacing a running folder is a different mechanism with real risk, and the installer exists for people who want updates applied for them. The portable build shows a quiet notice when a newer version exists and Help ▸ Check for updates links to the download; nothing is replaced behind your back.

What does 'unknown' mean in Check for updates?

The app could not find out: it is offline, the release index returned an error, or the manifest was malformed. It is shown as a warning, never as 'up to date', because an offline app must not tell you that you are current.

How often does the installer build check?

The Squirrel updater checks shortly after launch and then every four hours, downloads in the background and offers Restart now / Later. Help ▸ Check for updates also nudges it, so a manual check does not leave you waiting for the next cycle.