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

- Canonical: https://cwdio.com/blog/every-build-can-learn-about-an-update/
- Updated: 2026-08-15
- Product: Cwdio File Manager (cwdio) — https://cwdio.com

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](/docs/install-and-update/); what shipped when is in the
[changelog](/changelog/).

## Frequently asked 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.
